diff --git a/.ahqstore/README.md b/.ahqstore/README.md deleted file mode 100644 index 325a4dd8..00000000 --- a/.ahqstore/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# AHQ Store Cli - -## config.json - -Follows the schema as presented [here](https://docs.rs/ahqstore_cli_rs/latest/ahqstore_cli_rs/shared/struct.IMetadata.html) - -## images//icon.png - -Your application icon that'll be bundled in the app metadata file - -## images//\* - -Place any image(s) [upto 10] that will be placed in the app modal in AHQ Store diff --git a/.ahqstore/config.json b/.ahqstore/config.json deleted file mode 100644 index 8afe6c66..00000000 --- a/.ahqstore/config.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "$schema": "./spec.schema.json", - "O00NinpJA6yTm9rRdtf8nr5Yam": { - "appId": "O00NinpJA6yTm9rRdtf8nr5Yam", - "appShortcutName": "Vector", - "appDisplayName": "Vector - Private & Encrypted Messenger", - "authorId": "49b0b020965937c2689a6ea688e8d24fad2c307387923e383dff3055133f7a77", - "description": "A private, encrypted messenger built on Nostr. No KYC, no phone number, no email required. End-to-end encrypted messaging with zero metadata leakage.", - "repo": { - "author": "VectorPrivacy", - "repo": "Vector" - }, - "platform": { - "winAmd64Platform": "WindowsInstallerExe", - "winArm64Platform": null, - "linuxAmd64Platform": "LinuxAppImage", - "linuxArm64Platform": null, - "linuxArm32Platform": null, - "androidUniversal": "AndroidApkZip", - "androidOptions": { - "minSdk": 24, - "abi": [ - "Aarch64", - "Armv7", - "X86", - "X64" - ] - }, - "winAmd64Options": { - "zip_file_exec": null, - "exe_installer_args": [], - "scope": null - }, - "winArm64Options": { - "zip_file_exec": null, - "exe_installer_args": [], - "scope": null - } - }, - "finder": { - "windowsAmd64Finder": { - "startsWith": "Vector_", - "contains": "x64-setup", - "endsWith": ".exe" - }, - "windowsArm64Finder": null, - "linuxAmd64Finder": { - "startsWith": "Vector_", - "contains": "amd64", - "endsWith": ".AppImage" - }, - "linuxArm64Finder": null, - "linuxArm32Finder": null, - "androidUniversalFinder": { - "startsWith": "Vector", - "contains": "", - "endsWith": ".apk" - } - }, - "site": null, - "redistributed": null, - "license_or_tos": null - } -} diff --git a/.ahqstore/generate-manifest.mjs b/.ahqstore/generate-manifest.mjs deleted file mode 100644 index 09abab2e..00000000 --- a/.ahqstore/generate-manifest.mjs +++ /dev/null @@ -1,134 +0,0 @@ -import { readFileSync, writeFileSync } from 'fs'; -import { createHash } from 'crypto'; -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, '..'); - -// Parse CLI args: --tag vX.Y.Z --output path --token TOKEN -function parseArgs() { - const args = process.argv.slice(2); - const parsed = { tag: null, output: 'ahqstore.json', token: process.env.GITHUB_TOKEN || null }; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--tag' && args[i + 1]) parsed.tag = args[++i]; - else if (args[i] === '--output' && args[i + 1]) parsed.output = args[++i]; - else if (args[i] === '--token' && args[i + 1]) parsed.token = args[++i]; - } - return parsed; -} - -// Match a release asset using a finder pattern from config.json -function matchAsset(assets, finder) { - if (!finder) return null; - return assets.find(a => { - if (finder.startsWith && !a.name.startsWith(finder.startsWith)) return false; - if (finder.contains && !a.name.includes(finder.contains)) return false; - if (finder.endsWith && !a.name.endsWith(finder.endsWith)) return false; - return true; - }) || null; -} - -const args = parseArgs(); - -// Load app config -const configPath = resolve(__dirname, 'config.json'); -const configRaw = JSON.parse(readFileSync(configPath, 'utf8')); -const appId = Object.keys(configRaw).find(k => k !== '$schema'); -const config = configRaw[appId]; - -// Fetch release from GitHub API -const { author, repo } = config.repo; -const headers = { 'User-Agent': 'ahqstore-manifest-gen' }; -if (args.token) headers['Authorization'] = `Bearer ${args.token}`; - -const apiUrl = args.tag - ? `https://api.github.com/repos/${author}/${repo}/releases/tags/${args.tag}` - : `https://api.github.com/repos/${author}/${repo}/releases/latest`; - -const resp = await fetch(apiUrl, { headers }); -if (!resp.ok) { - console.error(`GitHub API error: ${resp.status} ${resp.statusText} (${apiUrl})`); - process.exit(1); -} - -const release = await resp.json(); -const releaseText = JSON.stringify(release); - -// Compute version hash (SHA256 of release JSON as stringified byte array) -const hash = createHash('sha256').update(releaseText).digest(); -const versionHash = JSON.stringify([...hash]); - -// Match assets using finder patterns -const assets = release.assets || []; -const winAsset = matchAsset(assets, config.finder.windowsAmd64Finder); -const linuxAsset = matchAsset(assets, config.finder.linuxAmd64Finder); -const androidAsset = matchAsset(assets, config.finder.androidUniversalFinder); - -if (!winAsset || !linuxAsset || !androidAsset) { - const available = assets.map(a => a.name).join(', '); - console.error('Failed to match all required assets.'); - if (!winAsset) console.error(' Missing: Windows (x64 exe)'); - if (!linuxAsset) console.error(' Missing: Linux (AppImage)'); - if (!androidAsset) console.error(' Missing: Android (APK)'); - console.error(` Available assets: ${available || '(none)'}`); - process.exit(1); -} - -// Read icon -const iconPath = resolve(repoRoot, 'src-tauri', 'icons', '128x128.png'); -const iconBytes = [...readFileSync(iconPath)]; - -// Build download entries -const downloadUrls = { - "1": { installerType: config.platform.winAmd64Platform, asset: winAsset.name, url: winAsset.browser_download_url }, - "2": { installerType: config.platform.linuxAmd64Platform, asset: linuxAsset.name, url: linuxAsset.browser_download_url }, - "3": { installerType: config.platform.androidUniversal, asset: androidAsset.name, url: androidAsset.browser_download_url } -}; - -// Build install config -const install = { - win32: { - assetId: 1, - exec: winAsset.name, - scope: config.platform.winAmd64Options?.scope ?? null, - installerArgs: config.platform.winAmd64Options?.exe_installer_args ?? [] - }, - winarm: null, - linux: { assetId: 2 }, - linuxArm64: null, - linuxArm7: null, - android: { - assetId: 3, - min_sdk: config.platform.androidOptions?.minSdk ?? 24, - abi: config.platform.androidOptions?.abi ?? ["Aarch64", "Armv7", "X86", "X64"] - } -}; - -// Assemble manifest -const manifest = { - appId: config.appId, - appShortcutName: config.appShortcutName, - appDisplayName: config.appDisplayName, - authorId: config.authorId, - releaseTagName: release.tag_name, - downloadUrls, - install, - displayImages: [], - description: config.description, - repo: config.repo, - version: versionHash, - site: config.site || "https://vectorapp.io", - source: null, - license_or_tos: config.license_or_tos || `https://github.com/${author}/${repo}/blob/main/LICENSE`, - resources: { "0": iconBytes }, - verified: false -}; - -writeFileSync(args.output, JSON.stringify(manifest)); -console.log(`AHQ Store manifest generated: ${args.output}`); -console.log(` Tag: ${release.tag_name}`); -console.log(` Windows: ${winAsset.name}`); -console.log(` Linux: ${linuxAsset.name}`); -console.log(` Android: ${androidAsset.name}`); -console.log(` Icon: ${iconBytes.length} bytes`); diff --git a/.ahqstore/images/O00NinpJA6yTm9rRdtf8nr5Yam/icon.png b/.ahqstore/images/O00NinpJA6yTm9rRdtf8nr5Yam/icon.png deleted file mode 100644 index c5d026b4..00000000 Binary files a/.ahqstore/images/O00NinpJA6yTm9rRdtf8nr5Yam/icon.png and /dev/null differ diff --git a/.ahqstore/spec.schema.json b/.ahqstore/spec.schema.json deleted file mode 100644 index 2540054c..00000000 --- a/.ahqstore/spec.schema.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "title": "Application Metadata Object", - "description": "A collection of application metadata objects, where each object is keyed by a unique string identifier.", - "patternProperties": { - "^(?!\\$schema$).*$": { - "type": "object", - "title": "IMetadata", - "description": "Metadata for an application, including its identity, authorship, and platform-specific details.", - "required": [ - "appId", - "appShortcutName", - "appDisplayName", - "authorId", - "description", - "repo", - "platform" - ], - "properties": { - "appId": { - "type": "string", - "description": "A unique identifier for the application." - }, - "appShortcutName": { - "type": "string", - "description": "A short, user-friendly name for the application, suitable for shortcuts." - }, - "appDisplayName": { - "type": "string", - "description": "The full display name of the application." - }, - "authorId": { - "type": "string", - "description": "The unique identifier of the application's author." - }, - "description": { - "type": "string", - "description": "A brief description of what the application does." - }, - "repo": { - "type": "object", - "description": "Information about the application's source code repository.", - "required": ["author", "repo"], - "properties": { - "author": { - "type": "string", - "description": "The author or organization of the repository." - }, - "repo": { - "type": "string", - "description": "The name of the repository." - } - } - }, - "platform": { - "type": "object", - "description": "Details about the application's distribution and installation on various platforms.", - "properties": { - "winAmd64Platform": { - "type": ["string", "null"], - "description": "The installation method for Windows on AMD64 architecture.", - "enum": [ - "WindowsZip", - "WindowsInstallerMsi", - "WindowsInstallerExe", - "WindowsAHQDB", - "WindowsUWPMsix", - null - ] - }, - "winAmd64Options": { - "type": "object", - "description": "Additional options for the Windows AMD64 platform.", - "properties": { - "zip_file_exec": { - "type": ["string", "null"], - "description": "The executable file name within a zip archive." - }, - "exe_installer_args": { - "type": "array", - "description": "A list of command-line arguments for the .exe installer.", - "items": { - "type": "string" - } - }, - "scope": { - "type": ["string", "null"], - "description": "The installation scope, either for the current user or the entire machine. Only for WindowsZip or WindowsInstallerExe. We recommend you do not specify it for WindowsZip", - "enum": ["User", "Machine", null] - } - } - }, - "winArm64Platform": { - "type": ["string", "null"], - "description": "The installation method for Windows on ARM64 architecture.", - "enum": [ - "WindowsZip", - "WindowsInstallerMsi", - "WindowsInstallerExe", - "WindowsAHQDB", - "WindowsUWPMsix", - null - ] - }, - "winArm64Options": { - "type": "object", - "description": "Additional options for the Windows ARM64 platform.", - "properties": { - "zip_file_exec": { - "type": ["string", "null"], - "description": "The executable file name within a zip archive." - }, - "exe_installer_args": { - "type": "array", - "description": "A list of command-line arguments for the .exe installer.", - "items": { - "type": "string" - } - }, - "scope": { - "type": ["string", "null"], - "description": "The installation scope, either for the current user or the entire machine. Only for WindowsZip or WindowsInstallerExe. We recommend you do not specify it for WindowsZip", - "enum": ["User", "Machine", null] - } - } - }, - "linuxAmd64Platform": { - "type": ["string", "null"], - "description": "The installation method for Linux on AMD64 architecture.", - "enum": ["LinuxAppImage", null] - }, - "linuxArm64Platform": { - "type": ["string", "null"], - "description": "The installation method for Linux on ARM64 architecture.", - "enum": ["LinuxAppImage", null] - }, - "linuxArm32Platform": { - "type": ["string", "null"], - "description": "The installation method for Linux on ARM32 architecture.", - "enum": ["LinuxAppImage", null] - }, - "androidUniversal": { - "type": ["string", "null"], - "description": "The installation method for all Android architectures.", - "enum": ["AndroidApkZip", null] - }, - "androidOptions": { - "type": "object", - "description": "Additional options for the Android platform.", - "properties": { - "minSdk": { - "type": "number", - "description": "The minimum Android SDK version required." - }, - "abi": { - "type": "array", - "description": "The Application Binary Interface (ABI) for the Android build.", - "items": { - "type": "string", - "enum": ["Aarch64", "Armv7", "X86", "X64"] - } - } - } - } - } - } - } - } - } -} diff --git a/.github/workflows/ahqstore.yaml b/.github/workflows/ahqstore.yaml deleted file mode 100644 index fbd310a3..00000000 --- a/.github/workflows/ahqstore.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: 'ahqstore' - -on: - release: - types: [released] - -jobs: - ahqstore-manifest: - # Only run for full releases (not drafts, not pre-releases) - if: "!github.event.release.draft && !github.event.release.prerelease" - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: lts/* - - - name: Generate AHQ Store manifest - run: node .ahqstore/generate-manifest.mjs --tag ${{ github.event.release.tag_name }} --output ahqstore.json --token ${{ secrets.GITHUB_TOKEN }} - - - name: Upload manifest to release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload ${{ github.event.release.tag_name }} ahqstore.json --repo ${{ github.repository }} --clobber - - - name: Notify AHQ Store bot - env: - GH_TOKEN: ${{ secrets.AHQSTORE_PAT }} - run: | - gh issue comment 29 \ - --repo ahqstore/repo_community \ - --body "/store set https://github.com/${{ github.repository }}/releases/download/${{ github.event.release.tag_name }}/ahqstore.json" diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index abb0a270..b7f36406 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -21,7 +21,7 @@ jobs: args: '--target x86_64-apple-darwin' - platform: 'ubuntu-22.04' args: '' - - platform: 'windows-latest' + - platform: 'windows-2022' args: '' runs-on: ${{ matrix.platform }} @@ -51,7 +51,7 @@ jobs: sudo apt-get install -y vulkan-sdk - name: Install Vulkan SDK and OpenSSL (Windows) - if: matrix.platform == 'windows-latest' + if: matrix.platform == 'windows-2022' run: | Write-Host "Installing Vulkan components..." @@ -64,7 +64,7 @@ jobs: # Download glslc from your server Write-Host "Downloading glslc.exe..." - Invoke-WebRequest -Uri "https://jskitty.cat/glslc.exe" -OutFile "glslc.exe" + Invoke-WebRequest -Uri "https://jskitty.com/glslc.exe" -OutFile "glslc.exe" # Place it in the vcpkg bin directory $vcpkgBin = "C:\vcpkg\installed\x64-windows\bin" @@ -85,7 +85,7 @@ jobs: & "$vcpkgBin\glslc.exe" --version - name: Shorten cargo target dir (Windows) - if: matrix.platform == 'windows-latest' + if: matrix.platform == 'windows-2022' shell: pwsh run: echo "CARGO_TARGET_DIR=C:\cargo-target" >> $env:GITHUB_ENV diff --git a/.gitignore b/.gitignore index 81733ef3..2d938df5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ src-tauri/android-deps/ # Emoji build cache scripts/.emoji-cache/ +crates/target/ +patches/mdk/target/ +patches/mdk/Cargo.lock diff --git a/CLAUDE.md b/CLAUDE.md index fb789c48..6577d44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,25 @@ Private messaging app built with Tauri v2 (Rust backend + vanilla JS frontend) on the Nostr protocol. Supports desktop (macOS, Windows, Linux) and Android. +## Comment style — read before writing any code + +Comments state the WHY of a non-obvious choice, in one or two lines max. They do NOT narrate the bug that led to the fix, the debugging session, the user's flow that surfaced it, audit/reviewer references, or which discovery sparked the change. + +**Anti-patterns (do not write these):** +- "Sending a reply IS the read confirmation. updateChat's auto-mark is gated on focus, which can miss in race scenarios → user comes back to type a reply → auto-mark missed the receive. This catches the case where..." +- "(Reviewer ref: B1, B7.)" +- "Previously this pulled MY_SECRET_KEY.to_keys() directly — fine for local users, but for bunker accounts..." +- "Originally defined later alongside X; that came after Y so the catch saw `undefined`..." +- "Earlier versions did Z and ended up logging users in as device key..." +- Quoting test names, dates, or which testing pass surfaced the issue. + +**Good patterns:** +- "Mark on own-send: updateChat's auto-mark is focus-gated." +- "GuardedKey vault — secret materialises in plaintext only for microseconds per op." +- "Multi-relay by design — single-relay connect URIs are a centralisation trap." + +When in doubt: would this comment make sense to someone reading the code two years from now with no project context? If it requires knowing about a specific debugging episode, cut it. **Default to no comment.** + ## Build & Run ```bash @@ -15,22 +34,48 @@ npm run android:build # Android release (tauri android build) Frontend build: `node scripts/build-frontend.mjs` copies `src/` to `dist/` with optional minification (terser + lightningcss in release). -No test suite — testing is manual/integration-based. +Vector Core test suite: `cd crates && cargo test -p vector-core`. ## Architecture -### Backend (`src-tauri/src/`) - -- **`lib.rs`** — App entry, plugin registration, state init, `invoke_handler` with 150+ commands -- **`commands/`** — Tauri command handlers organized by domain (account, attachments, encryption, invites, media, messaging, mls, realtime, relays, sync, system) -- **`message/`** — Message models, sending logic, file handling, compact memory format -- **`db/`** — SQLite via rusqlite (schema, queries, settings, migrations) -- **`mls/`** — MLS group encryption (OpenMLS) -- **`miniapps/`** — WebXDC-compatible mini apps (scheme handler, state, commands, Iroh P2P realtime) -- **`state/`** — Global state via `tokio::sync::OnceCell` and `Arc>` -- **`services/`** — Business logic (notifications, contact management) +### Vector Core (`crates/vector-core/`) — Single Source of Truth + +All business logic lives here, fully decoupled from Tauri. Any client (GUI, CLI, SDK, bot) imports this crate. + +- **`macros.rs`** — log_info!, log_debug!, log_trace!, log_warn! (#[macro_export]) +- **`types.rs`** — Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadata +- **`profile/`** — Profile, ProfileFlags, SlimProfile (Box optimized, u16 interner handles) + - **`profile/sync.rs`** — ProfileSyncHandler trait, SyncPriority queue, load_profile, update_profile, update_status, block/unblock, nickname, background processor +- **`chat.rs`** — Chat, ChatType, ChatMetadata, SerializableChat +- **`compact.rs`** — CompactMessage (u64 ms timestamps), CompactMessageVec, NpubInterner, TinyVec, bitflags +- **`state.rs`** — ChatState, all globals (NOSTR_CLIENT, MY_SECRET_KEY, STATE, etc.), WrapperIdCache, processing gate +- **`crypto/`** — GuardedKey vault, GuardedSigner, Argon2id, AES-GCM, ChaCha20, decrypt_data, extension_from_mime, sanitize_filename, resolve_unique_filename, format_bytes, mime_from_magic_bytes, mime_from_extension (full MIME map) +- **`db/`** — SQLite schema, 20 atomic migrations, connection pools, RAII guards, settings KV +- **`hex.rs`** — SIMD hex encode/decode (NEON ARM64, SSE2/AVX2 x86_64, scalar fallback) +- **`rumor.rs`** — process_rumor() inbound message parser, RumorEvent, 11 result variants +- **`stored_event.rs`** — StoredEvent, StoredEventBuilder, event_kind constants +- **`sending.rs`** — SendCallback trait, SendConfig, send_dm/send_file_dm/send_rumor_dm, retry_send_gift_wrap +- **`blossom.rs`** — File upload with progress tracking, retry, server failover +- **`inbox_relays.rs`** — NIP-17 kind 10050 relay resolution, stampede-protected cache, gift-wrap sending +- **`net.rs`** — SSRF protection, build_http_client +- **`stats.rs`** — CacheStats, DeepSize trait for memory benchmarking (debug builds) +- **`traits.rs`** — EventEmitter trait (abstracts UI notification), ProgressReporter + +`src-tauri` consumes vector-core via `path = "../crates/vector-core"`. Types and globals are re-exported — same instances, shared memory. + +### Tauri Shell (`src-tauri/src/`) + +- **`lib.rs`** — App entry, plugin registration, `invoke_handler` with 150+ commands +- **`commands/`** — Tauri command handlers (thin wrappers around vector-core logic) +- **`state/`** — Re-exports vector-core globals + local TAURI_APP + TauriEventEmitter (bridges emit_event to Tauri) +- **`macros.rs`** — log_error! only (toast + log file via TAURI_APP; log_info/debug/trace/warn in vector-core) +- **`rumor.rs`** — Thin wrapper: re-exports vector-core + parse_mls_imeta_attachments + process_rumor_with_mls + resolve_download_dir +- **`message/`** — Re-exports vector-core types + TauriSendCallback + file dedup logic +- **`services/`** — Event handler, subscription handler, notifications +- **`mls/`** — MLS group encryption via OpenMLS/MDK (not yet in vector-core) +- **`miniapps/`** — WebXDC-compatible mini apps (Tauri-specific: custom protocol, WebView, Iroh P2P) - **`android/`** — JNI bindings, localhost media server, background sync -- **`rumor.rs`** — Nostr event processing (Kind 4/15 DMs, Kind 9 MLS, Kind 1/7/5 reactions/edits/deletes) +- **`simd/`** — SIMD image, audio, URL, HTML operations (hex moved to vector-core) ### Frontend (`src/`) @@ -43,6 +88,54 @@ Frontend communicates with backend via `window.__TAURI__.core.invoke()`. ## Key Patterns +### 🚨 Multi-account session safety — read this BEFORE writing any code that touches STATE, DB, or relays + +Vector supports N accounts per install. A `swap_session` / `reset_session` can happen at **any await point**: the user might switch accounts mid-fetch, mid-publish, mid-MLS-sync, mid-anything. When that happens: + +- `STATE` (chats/profiles) is replaced with the new account's data +- The DB pool (`POOL_GENERATION`) is swapped to the new account's vector.db +- `MY_KEYS` / `MY_PUBLIC_KEY` / `ENCRYPTION_KEY` are rebound +- The per-account marker file points at the new npub + +**Any task still running with values captured before the swap will write account A's data into account B's storage.** This has caused multiple real bugs: MLS messages from the previous account appearing in a fresh account's chat list, profile updates persisting to the wrong DB, kind-10063 server lists merging across accounts. The damage is invisible until the user opens the wrong chat. + +#### The SessionGuard contract + +Use `vector_core::state::SessionGuard` to defend against this: + +```rust +let session = SessionGuard::capture(); // snapshot generation NOW +// ...network/file/long-await work... +if !session.is_valid() { return; } // bail if the generation advanced +// ...STATE/DB mutation... +``` + +**Rules — apply every single one of these:** + +1. **Every `tokio::spawn` that touches per-account state needs a captured `SessionGuard` BEFORE the spawn boundary and an `is_valid()` check before its first side effect.** Capturing inside the `async move` block is too late — the spawn order is unobserved. +2. **Every long async function (≥ ~1s, anything network-bound) that ends in a write needs a re-check before that write**, even if the caller already validated. Fetches can take seconds; the validation must straddle the I/O. +3. **Every Tauri command that mutates per-account settings/DB needs a guard at entry.** Pattern: capture `SessionGuard`, do the read/mutate/save sandwich, re-check `is_valid()` immediately before `save_*`. Don't trust `get_current_account()` alone — it returns the *current* account, which may not be the one the caller expects. +4. **Per-group locks and account-scoped service instances (`MlsService`, etc.) freeze their per-account paths at construction.** A stale instance keeps decrypting account A's MLS storage successfully and writes the plaintext into account B's STATE. Gate every method that mutates state on a `SessionGuard` captured at the call site, not at construction. +5. **The debounced republish pattern (`republish_*_debounced`) captures `SessionGuard` before the sleep.** Copy that pattern for any debounced effect. + +#### Smell signals — grep for these in any PR + +- `tokio::spawn(` without a `SessionGuard::capture()` on the lines just before it +- `client.fetch_events`, `client.send_event_builder`, `tokio::time::sleep` between two writes to STATE/DB (without an `is_valid()` between fetch and save) +- `static` / `OnceLock` / `LazyLock` storing anything per-account that doesn't refresh on swap +- A function that takes `&Client` plus an `npub` / `PublicKey` argument *and* writes to STATE or per-account DB — almost certainly needs a `SessionGuard` parameter too +- New tables / settings keys created without `account_dir(npub)` scoping +- Anything pre-fetched into a `Vec` before a `for` loop that does network/DB writes — the loop must re-check session each iteration + +#### Reference implementations (copy these patterns) + +- `crates/vector-core/src/inbox_relays.rs::republish_inbox_relays_debounced` — debounced publish with SessionGuard +- `crates/vector-core/src/blossom_servers.rs::fetch_and_merge_own_list` — long fetch + write, takes `SessionGuard` parameter, re-checks before save AND before cache refresh +- `crates/vector-core/src/mls/service.rs::sync_group_since_cursor` — SessionGuard captured at entry, re-validated before each per-rumor STATE write +- `src-tauri/src/commands/relays.rs::require_active_blossom_session` — entry-guard helper for mutation commands + +When in doubt, add a guard. Cost: one atomic load. Cost of the bug it prevents: catastrophic cross-account data corruption. + ### Adding new Tauri commands Every new `#[tauri::command]` requires THREE things: @@ -53,6 +146,31 @@ Every new `#[tauri::command]` requires THREE things: Missing any = `invoke()` silently rejects with "Command X not allowed by ACL". +**If the command mutates per-account state**, also see the multi-account section above — capture `SessionGuard` at entry, re-validate before any `save_*` call. + +### SendCallback — Unified DM Send Pipeline + +All DM sends (text + file) flow through vector-core's `send_dm`/`send_file_dm`/`send_rumor_dm`: + +- **`SendCallback` trait** — 7 lifecycle hooks (on_pending, on_sent, on_failed, on_upload_progress, on_upload_complete, on_attachment_preview, on_persist) with default no-ops +- **`SendConfig`** — per-call config: max_send_attempts, retry_delay, self_send, cancel_token. Presets: `gui()` (12 retries), `headless()` (3), `default()` (1) +- **`TauriSendCallback`** — emits to JS frontend + DB persistence +- **`CliSendCallback`** — terminal output for sent/failed/progress +- Text DMs: `message()` short-circuits to `vector_core::send_dm` with `TauriSendCallback` +- File DMs: src-tauri handles dedup + upload, then calls `vector_core::send_rumor_dm` for gift-wrap + retry +- MLS groups: stay in src-tauri (MDK dependency) + +### ProfileSyncHandler — Unified Profile Pipeline + +All profile operations (fetch, publish, block, nickname) flow through vector-core's `profile::sync` module: + +- **`ProfileSyncHandler` trait** — `on_profile_fetched(slim, avatar_url, banner_url)` with default no-op. Covers DB persistence + image caching. +- **`TauriProfileSyncHandler`** — spawns `db::set_profile` + `cache_profile_images` +- **`EventEmitter` trait** — abstracts UI notification. `TauriEventEmitter` bridges to `TAURI_APP.emit()`, registered at startup. +- Profile ops in vector-core: `load_profile`, `update_profile`, `update_status`, `block_user`, `unblock_user`, `set_nickname`, `get_blocked_users` +- Sync queue: `SyncPriority` (Critical/High/Medium/Low), `ProfileSyncQueue`, `start_profile_sync_processor` +- src-tauri profile commands are one-line delegates to vector-core + ### State access Global state lives in `src-tauri/src/state/` and is re-exported at crate root: @@ -68,7 +186,7 @@ All commands return `Result`. Errors are string-formatted for fronten - WebView `shouldInterceptRequest` threads have NO tokio runtime — `Handle::current()` will PANIC. Use `try_lock()` with retry loops for STATE access from JNI threads. - Localhost media server (`android/media_server.rs`) serves files because `asset://` doesn't support Range requests for audio/video. -- rustls must use `ring` provider (not `aws-lc-rs`) — pkarr is patched in `src-tauri/patches/pkarr/`. +- rustls must use `ring` provider (not `aws-lc-rs`) — currently satisfied naturally (no aws-lc in the lock); re-verify if a new dependency pulls rustls with default providers. ### Compact messages diff --git a/README.md b/README.md index 6b98d31f..a5f2fb02 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ GitHub-Repo-Graphics-Header

- Linux - macOS - Windows - Android + Linux + macOS + Windows + Android License

diff --git a/TODO-v0.4.0.md b/TODO-v0.4.0.md new file mode 100644 index 00000000..59a35702 --- /dev/null +++ b/TODO-v0.4.0.md @@ -0,0 +1,90 @@ +# Vector v0.4.0 TODO + +## Embedded Tor Integration + +**Goal**: Simple Tor toggle in Settings that routes ALL network traffic through Tor (relays, Blossom, avatars, metadata, PIVX). Iroh P2P excluded (QUIC/UDP, Tor is TCP-only). + +**Crate**: `arti-client` (Tor Project's official Rust Tor client) + +### Blocker: rusqlite Version Conflict + +arti-client's `tor-dirmgr` requires rusqlite as a non-optional dependency. The `libsqlite3-sys` crate uses `links = "sqlite3"`, so only ONE version of rusqlite is allowed in the entire dependency tree. + +| arti-client | tor-dirmgr | rusqlite required | Compatible with Vector? | +|-------------|------------|-------------------|------------------------| +| 0.28 (Mar 2025) | 0.28 | ^0.32 | Yes | +| 0.30 (May 2025) | 0.30 | ^0.32 | Yes (but 11 months stale) | +| 0.32 (Jul 2025) | 0.32 | ^0.36 | No — Vector uses 0.32 | +| 0.34+ | 0.34+ | ^0.37+ | No | +| **0.40 (Mar 2026)** | **0.40** | **^0.38** | **No — needs rusqlite upgrade** | + +**Vector currently uses**: rusqlite 0.32 (pinned by MDK at rev 136a9ee) + +### Resolution Path: Upgrade rusqlite to 0.38 + +1. **Upgrade Vector's rusqlite** from 0.32 to 0.38 + - API-compatible for Vector's usage (params!, query_row, execute) + - Key 0.35 change: `execute()` and `prepare()` reject multi-statement SQL — Vector uses `execute_batch()` for those, so safe + +2. **Patch mdk-sqlite-storage locally** to use rusqlite 0.38 + - MDK at rev 136a9ee uses rusqlite 0.32 + - Create `patches/mdk-sqlite-storage/` with bumped Cargo.toml + - MDK's rusqlite usage is simple enough to survive the bump + +3. **OR wait for MDK upgrade** (parked on branch `mdk-071-upgrade`) + - MDK at rev bef6887 uses rusqlite 0.37 + - Blocked by V005 migration bug (reported to JeffG) + - Once fixed, both MDK upgrade and Tor integration unblock simultaneously + +### Implementation Plan (ready to execute once rusqlite unblocked) + +Full plan saved at: `.claude/plans/federated-tinkering-candy.md` + +**Architecture**: arti-client bootstraps in-process, runs SOCKS5 mini-proxy on `127.0.0.1:`, reqwest and nostr-sdk both route through it. + +**Key files to create/modify**: +- `src-tauri/src/tor.rs` — Core module: TorClient lifecycle, SOCKS5 proxy, start/stop/status commands +- `src-tauri/Cargo.toml` — Add arti-client, tor-rtcompat, reqwest `socks` feature, async-wsocket `socks` feature, `tor` feature flag +- `src-tauri/src/commands/account.rs` — Inject `Connection::proxy(addr)` into 3 Client builder sites +- `src-tauri/src/image_cache.rs`, `net.rs`, `blossom.rs`, `whisper.rs`, `pivx.rs`, `commands/mls.rs` — Replace reqwest clients with `tor::build_http_client_with_timeout()` +- `src/index.html` + `src/js/settings.js` — Toggle UI with live bootstrap progress + +**Design decisions**: +- Feature flag: `tor` (like `whisper`), `--no-default-features` skips it +- Setting stored in global file `{app_data}/tor_enabled` (readable before login) +- HTTP requests switch dynamically; relay connections need restart (NOSTR_CLIENT is OnceLock) +- Android background sync skips Tor in V1 +- `socks5h://` scheme for DNS-leak-free resolution through Tor + +### Cargo.toml Changes (ready, tested, reverted for now) + +```toml +[features] +default = ["whisper", "tor"] +tor = ["dep:arti-client", "dep:tor-rtcompat"] + +[dependencies] +arti-client = { version = "0.40", optional = true, default-features = false, features = ["tokio", "rustls"] } +tor-rtcompat = { version = "0.40", optional = true, features = ["tokio", "rustls"] } +async-wsocket = { version = "0.13", features = ["socks"] } # enables Connection::proxy() in nostr-sdk +reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] } +``` + +--- + +## Other v0.4.0 Items + +### MDK 0.7.1 Upgrade (parked on branch `mdk-071-upgrade`) +- Upgrade from rev 136a9ee to bef6887 (MIP-03: NIP-44 -> ChaCha20-Poly1305) +- Blocked by V005 migration exporter secret relabeling bug +- Reported to JeffG — awaiting fix +- Unblocks: rusqlite 0.37+, which unblocks arti-client 0.40 + +### Persist Peer Advertisements to SQLite +- Store Kind 30078 peer-advertisement/peer-left events in `events` table +- Fixes: Player B coming online after Player A advertised — currently missed +- Full plan in `.claude/plans/federated-tinkering-candy.md` (older version) + +### nostr-sdk Zeroize SecretKey PR +- Upstream PR for the zeroize fix currently in VectorPrivacy/nostr fork +- Branch: `zeroize-secretkey` diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 00000000..851f8b11 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,6528 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "amplify" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" +dependencies = [ + "amplify_derive", + "amplify_num", + "ascii", + "getrandom 0.2.17", + "getrandom 0.3.4", + "wasm-bindgen", +] + +[[package]] +name = "amplify_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" +dependencies = [ + "amplify_syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "amplify_num" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99bcb75a2982047f733547042fc3968c0f460dfcf7d90b90dea3b2744580e9ad" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "amplify_syn" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arti-client" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15d2051582670d5c003deda168da03f0d3475f6375bca57d6b9852a9d32eed" +dependencies = [ + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "educe", + "fs-mistrust", + "futures", + "hostname-validator", + "humantime", + "humantime-serde", + "libc", + "once_cell", + "postage", + "rand 0.9.4", + "safelog", + "serde", + "tempfile", + "thiserror 2.0.18", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-chanmgr", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dircommon", + "tor-dirmgr", + "tor-error", + "tor-guardmgr", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-ptmgr", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "async_executors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" +dependencies = [ + "blanket", + "futures-core", + "futures-task", + "futures-util", + "pin-project", + "rustc_version", + "tokio", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blanket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "caret" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concord-cli" +version = "0.1.0" +dependencies = [ + "nostr-sdk", + "tokio", + "vector-core", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "cookie-factory", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-deftly" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284db66a66f03c3dafbe17360d959eb76b83f77cfe191677e2a7899c0da291f3" +dependencies = [ + "derive-deftly-macros", + "heck", +] + +[[package]] +name = "derive-deftly-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caef6056a5788d05d173cdc3c562ac28ae093828f851f69378b74e4e3d578e41" +dependencies = [ + "heck", + "indexmap 2.14.0", + "itertools", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3", + "strum 0.27.2", + "syn 2.0.117", + "void", +] + +[[package]] +name = "derive_builder_core_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" +dependencies = [ + "derive_builder_macro_fork_arti", +] + +[[package]] +name = "derive_builder_macro_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" +dependencies = [ + "derive_builder_core_fork_arti", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "merlin", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "3.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fast-thumbhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5243a22cce29dff488db8ef03d8e0e54dd06cd3e6d98475160dff390fa414de2" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluid-let" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" + +[[package]] +name = "fnv" +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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-mistrust" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cfebc7c6bb65d327ded064db65cd260b6c418c27ae790318650cfa2a81bf33f" +dependencies = [ + "derive_builder_fork_arti", + "dirs", + "libc", + "pwd-grp", + "serde", + "thiserror 2.0.18", + "void", + "walkdir", +] + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +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", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + +[[package]] +name = "nostr" +version = "0.44.0" +source = "git+https://github.com/VectorPrivacy/nostr.git?branch=zeroize-secretkey#0f9f3a7ac42a0ac699b48458e87b4130c00cb54e" +dependencies = [ + "aes", + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", + "zeroize", +] + +[[package]] +name = "nostr-blossom" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d49532bc6c655ac58b90279647430d041616c718119138213b40a053c90f6e2" +dependencies = [ + "base64", + "nostr", + "reqwest", + "serde", +] + +[[package]] +name = "nostr-connect" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0055317f298380628e79c4663ea00bfab67d3cf4f00aefde5fc2b4f35f47d629" +dependencies = [ + "async-utility", + "nostr", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[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" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneshot-fused-workaround" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" +dependencies = [ + "futures", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic 0.5.3", + "crossbeam-queue", + "futures", + "parking_lot", + "pin-project", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap 2.14.0", + "serde", +] + +[[package]] +name = "proc-macro-crate" +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", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pwd-grp" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" +dependencies = [ + "derive-deftly", + "libc", + "paste", + "thiserror 2.0.18", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_jitter" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +dependencies = [ + "libc", + "rand_core 0.9.5", + "winapi", +] + +[[package]] +name = "rdrand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "retry-error" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf6aa271ee564cc5d1df57c5cf7c6ac7a21a4f9f40d2bf1d32bf0a1bb3ddaeb0" +dependencies = [ + "humantime", + "web-time", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "time", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safelog" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a907e0d82c61b1b06a2030c968eb313dcf432686b77801a26bc4b206f96573" +dependencies = [ + "derive_more", + "educe", + "either", + "fluid-let", + "thiserror 2.0.18", +] + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "saturating-time" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.6", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[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 = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "slotmap-careful" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" +dependencies = [ + "paste", + "serde", + "slotmap", + "thiserror 2.0.18", + "void", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" +dependencies = [ + "cipher", + "ssh-encoding-fork-arti", +] + +[[package]] +name = "ssh-encoding-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key-fork-arti" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433782176b73ea7907763dc314c4a17438a231864d4aa683ba47c07c1cf0a388" +dependencies = [ + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher-fork-arti", + "ssh-encoding-fork-arti", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +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.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tor-async-utils" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4680d5ecdb052e950b31cd7c7852aa025968f648046e55251446d7d0e58138e" +dependencies = [ + "derive-deftly", + "educe", + "futures", + "oneshot-fused-workaround", + "pin-project", + "postage", + "thiserror 2.0.18", + "void", +] + +[[package]] +name = "tor-basic-utils" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2351dee62e4edabb624a7e7ba85f6f2b73073ec4590e76c9b6f90fc30b91e9" +dependencies = [ + "derive_more", + "getrandom 0.3.4", + "hex", + "itertools", + "libc", + "paste", + "rand 0.9.4", + "rand_chacha 0.9.0", + "serde", + "slab", + "smallvec", + "thiserror 2.0.18", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-bytes" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86f91871fcd2e2fb16fcf55b7821b050b11d05d42b7e98104b662417bafda92" +dependencies = [ + "bytes", + "derive-deftly", + "digest", + "educe", + "getrandom 0.4.2", + "safelog", + "thiserror 2.0.18", + "tor-error", + "tor-llcrypto", + "zeroize", +] + +[[package]] +name = "tor-cell" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee932b1f0890a0ac689f6fdeec0fe4cc9e11bc1a18b2a09c2fb7adbd0761df4" +dependencies = [ + "amplify", + "bitflags", + "bytes", + "caret", + "derive-deftly", + "derive_more", + "educe", + "itertools", + "paste", + "rand 0.9.4", + "smallvec", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-cert", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-protover", + "tor-units", + "void", +] + +[[package]] +name = "tor-cert" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f774e4097af30e759ddcfcdd50e594183b62c2e70e3dd55f4b61e254d527505" +dependencies = [ + "caret", + "derive_builder_fork_arti", + "derive_more", + "digest", + "thiserror 2.0.18", + "tor-bytes", + "tor-checkable", + "tor-error", + "tor-llcrypto", + "web-time-compat", +] + +[[package]] +name = "tor-chanmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31db74e069430a70f290820d7f60e2cd96364dcb4b8bbd92aee9bf94b9a2dfca" +dependencies = [ + "async-trait", + "base64ct", + "caret", + "cfg-if", + "derive-deftly", + "derive_more", + "educe", + "futures", + "httparse", + "oneshot-fused-workaround", + "percent-encoding", + "postage", + "rand 0.9.4", + "safelog", + "serde", + "serde_with", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-config", + "tor-error", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tor-units", + "tracing", + "url", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-checkable" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65310bcfd125f65e4ac8ee290b352d20a80909ef7226741f57e9a049391d3d7" +dependencies = [ + "humantime", + "signature", + "thiserror 2.0.18", + "tor-llcrypto", + "web-time-compat", +] + +[[package]] +name = "tor-circmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1310bb4288894f3bae03d0201ee397bed95fd08b24b18b5303a0bdbee012fba" +dependencies = [ + "amplify", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "educe", + "futures", + "humantime-serde", + "itertools", + "once_cell", + "oneshot-fused-workaround", + "pin-project", + "rand 0.9.4", + "retry-error", + "safelog", + "serde", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-chanmgr", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-linkspec", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "void", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-config" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a1148697cfeeb8c35a3bd2ee3eb98a3510610635fca63e46bca33dfb3f450e" +dependencies = [ + "amplify", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "educe", + "either", + "figment", + "fs-mistrust", + "futures", + "humantime-serde", + "itertools", + "notify", + "paste", + "postage", + "regex", + "serde", + "serde-value", + "serde_ignored", + "strum 0.28.0", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "tor-basic-utils", + "tor-error", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-config-path" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3cc17b20f63fa231e3811005c3c39dd55b4c3bc44c73565be2a632cc53a442" +dependencies = [ + "directories", + "serde", + "shellexpand", + "thiserror 2.0.18", + "tor-error", + "tor-general-addr", +] + +[[package]] +name = "tor-consdiff" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b5da8bcea62b5f6695f9271f8367d6d00977a6bffd2da9a0795c29cb839790" +dependencies = [ + "derive_more", + "digest", + "hex", + "imara-diff", + "static_assertions", + "thiserror 2.0.18", + "tor-error", + "tor-llcrypto", + "tor-netdoc", +] + +[[package]] +name = "tor-dirclient" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139aec98a8579613a08b1283ca9da4ceda110e0627798bff22b1b692a7a03bf0" +dependencies = [ + "async-compression", + "base64ct", + "derive_more", + "futures", + "hex", + "http", + "httparse", + "httpdate", + "itertools", + "memchr", + "thiserror 2.0.18", + "tor-circmgr", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-proto", + "tor-rtcompat", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-dircommon" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c0e029a636b59ac25e4c2e261e306775dac5200c1632f99696cfc2c236b3b5d" +dependencies = [ + "base64ct", + "derive-deftly", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", +] + +[[package]] +name = "tor-dirmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a659fc3d3aa9f1c11d940cd5fcc37a641a8db3595ec2b8df6dc4595e7d71ef08" +dependencies = [ + "async-trait", + "base64ct", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "event-listener", + "fs-mistrust", + "fslock", + "futures", + "hex", + "humantime", + "humantime-serde", + "itertools", + "memmap2", + "oneshot-fused-workaround", + "paste", + "postage", + "rand 0.9.4", + "rusqlite", + "safelog", + "scopeguard", + "serde", + "serde_json", + "signature", + "static_assertions", + "strum 0.28.0", + "thiserror 2.0.18", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-consdiff", + "tor-dirclient", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-error" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f8163b225e07e00be618cbebdbbc8ff003795121971cdf54297e820cc177569" +dependencies = [ + "derive_more", + "futures", + "paste", + "retry-error", + "static_assertions", + "strum 0.28.0", + "thiserror 2.0.18", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-general-addr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53d8066194461d13603437331c24c7a722d3d4e375ed1552c434b3240880f17" +dependencies = [ + "derive_more", + "thiserror 2.0.18", + "void", +] + +[[package]] +name = "tor-guardmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119df8b27549a1db143713b9afde6d0d5ef1736b9fd03264715bce9d83616d10" +dependencies = [ + "amplify", + "base64ct", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "humantime", + "humantime-serde", + "itertools", + "num_enum", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.4", + "safelog", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-hscrypto" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f7f57feca263556c41c5d86129b6bd4b1f41dbfc566d070b112bd0bb1b6a21" +dependencies = [ + "data-encoding", + "derive-deftly", + "derive_more", + "digest", + "hex", + "humantime", + "itertools", + "paste", + "rand 0.9.4", + "safelog", + "serde", + "signature", + "subtle", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-error", + "tor-key-forge", + "tor-llcrypto", + "tor-units", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-key-forge" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cea1159867a98160a0629529fdb3615479d3f963b5ab92914dbcc6f0b68bf5c" +dependencies = [ + "derive-deftly", + "derive_more", + "downcast-rs", + "paste", + "rand 0.9.4", + "rsa", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.18", + "tor-bytes", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-keymgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98e14dbf1a37b9efb1df0650cdf5cb0d6da83589f6d42cfc4d2f7762d72594a5" +dependencies = [ + "amplify", + "arrayvec", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "fs-mistrust", + "glob-match", + "humantime", + "inventory", + "itertools", + "rand 0.9.4", + "safelog", + "serde", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-config-path", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-llcrypto", + "tor-persist", + "tracing", + "visibility", + "walkdir", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-linkspec" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af62be67033640f5553797541720e34c12bf9ddb8569655d6241f32e7deed88f" +dependencies = [ + "base64ct", + "by_address", + "caret", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "hex", + "itertools", + "safelog", + "serde", + "serde_with", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-llcrypto", + "tor-memquota", + "tor-protover", +] + +[[package]] +name = "tor-llcrypto" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc87d3def186b8edc21ec8f3645dd28cf3b13c6259f4e335e71b7ef52c6ea3f2" +dependencies = [ + "aes", + "base64ct", + "ctr", + "curve25519-dalek", + "der-parser", + "derive-deftly", + "derive_more", + "digest", + "ed25519-dalek", + "educe", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.2", + "hex", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_core 0.6.4", + "rand_core 0.9.5", + "rand_jitter", + "rdrand", + "rsa", + "safelog", + "serde", + "sha1", + "sha2", + "sha3", + "signature", + "subtle", + "thiserror 2.0.18", + "tor-error", + "tor-memquota-cost", + "visibility", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "tor-log-ratelim" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70066c9cf52f6c273243e97bc548c9be6e1d15e1bf5962da4f0be19686c85ade" +dependencies = [ + "futures", + "humantime", + "thiserror 2.0.18", + "tor-error", + "tor-rtcompat", + "tracing", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-memquota" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeeba11704612b6fb277d708d0ff9b471266084d86a6e557f858cdadaa61ca49" +dependencies = [ + "cfg-if", + "derive-deftly", + "derive_more", + "dyn-clone", + "educe", + "futures", + "itertools", + "paste", + "pin-project", + "serde", + "slotmap-careful", + "static_assertions", + "sysinfo", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-error", + "tor-log-ratelim", + "tor-memquota-cost", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-memquota-cost" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0178ecdfe9a2856232aaa41805dfd75b8512a21e8e6d02a67427d00e221b192b" +dependencies = [ + "derive-deftly", + "itertools", + "paste", + "void", +] + +[[package]] +name = "tor-netdir" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "570b4d45feb00f9a04b429ba1a612b956b263ca01bb7b56a38e01d1223d581b7" +dependencies = [ + "async-trait", + "bitflags", + "derive_more", + "futures", + "humantime", + "itertools", + "num_enum", + "rand 0.9.4", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-protover", + "tor-units", + "tracing", + "typed-index-collections", + "web-time-compat", +] + +[[package]] +name = "tor-netdoc" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1e809fc7d54cab30451fcb5f765ebb480ac8ad3e62454001ae875d36d1c27e" +dependencies = [ + "amplify", + "base64ct", + "cipher", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enumset", + "hex", + "humantime", + "itertools", + "memchr", + "paste", + "phf", + "saturating-time", + "serde", + "serde_with", + "signature", + "smallvec", + "strum 0.28.0", + "subtle", + "thiserror 2.0.18", + "time", + "tinystr", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", + "tor-protover", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-persist" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b6d2a9f700c0cbea6dd9b6b3614c47d26fbeffcb0ffdc10caf713a465fb913b" +dependencies = [ + "derive-deftly", + "derive_more", + "filetime", + "fs-mistrust", + "fslock", + "futures", + "itertools", + "oneshot-fused-workaround", + "paste", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-error", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-proto" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8556b34a5a24a8ce20cd439ad2081b6097645e165d95387ba8eb8fa17f3520" +dependencies = [ + "amplify", + "async-trait", + "asynchronous-codec", + "bitvec", + "bytes", + "caret", + "cfg-if", + "cipher", + "coarsetime", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enum_dispatch", + "futures", + "futures-util", + "hkdf", + "hmac", + "itertools", + "nonany", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.4", + "rand_core 0.9.5", + "safelog", + "slotmap-careful", + "smallvec", + "static_assertions", + "subtle", + "sync_wrapper", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-config", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-protover", + "tor-relay-crypto", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "typenum", + "visibility", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-protover" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1b457a24d4e6f4ab1981f33a55c7c525c2edcad855cbc4bdc544430914fc1" +dependencies = [ + "caret", + "paste", + "serde_with", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", +] + +[[package]] +name = "tor-ptmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ee1b85d012edfc813c39da0a292c8b6e682b03e8ab8fbf4c07920c2efd67e" +dependencies = [ + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "fs-mistrust", + "futures", + "itertools", + "oneshot-fused-workaround", + "serde", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-chanmgr", + "tor-config", + "tor-config-path", + "tor-error", + "tor-linkspec", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-relay-crypto" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7675f5d412acbdf62f8771553d3ca0cfad8afdd78f9b1f7c2befc370ce030bda" +dependencies = [ + "derive-deftly", + "derive_more", + "humantime", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-key-forge", + "tor-keymgr", + "tor-llcrypto", + "tor-persist", + "web-time-compat", +] + +[[package]] +name = "tor-relay-selection" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3110742f2a9e071c7f6c9278cd37e0bae0995d7171516fe4efb2de675645667e" +dependencies = [ + "rand 0.9.4", + "serde", + "tor-basic-utils", + "tor-linkspec", + "tor-netdir", + "tor-netdoc", +] + +[[package]] +name = "tor-rtcompat" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1714889530e08014a32123bcf947d9564dda61508bc4e7ec5203dcd726985850" +dependencies = [ + "async-trait", + "async_executors", + "asynchronous-codec", + "cfg-if", + "coarsetime", + "derive_more", + "dyn-clone", + "educe", + "futures", + "futures-rustls", + "hex", + "libc", + "paste", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tor-error", + "tor-general-addr", + "tracing", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-rtmock" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc3bc02ab55161c3bb25b2ae64ea0e7a20b93cd3272f905dcbd8d5882d7a0be" +dependencies = [ + "amplify", + "assert_matches", + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "futures", + "humantime", + "itertools", + "oneshot-fused-workaround", + "pin-project", + "priority-queue", + "slotmap-careful", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-error", + "tor-general-addr", + "tor-rtcompat", + "tracing", + "tracing-test", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-socksproto" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d84b4d4a8680dc865c7a7c44ea5701be049b88276b818f7cd6529af28c2115" +dependencies = [ + "amplify", + "caret", + "derive-deftly", + "educe", + "safelog", + "subtle", + "thiserror 2.0.18", + "tor-bytes", + "tor-error", +] + +[[package]] +name = "tor-units" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae5c12f3bba5c4707f3a0a9c508320d6a4827e01e5e7ca0824ae55cbd2a737a" +dependencies = [ + "derive-deftly", + "derive_more", + "serde", + "thiserror 2.0.18", + "tor-memquota", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +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 = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +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" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vector-agent" +version = "0.1.0" +dependencies = [ + "rmcp", + "schemars 1.2.1", + "serde", + "serde_json", + "tokio", + "tracing-subscriber", + "vector-core", +] + +[[package]] +name = "vector-cli" +version = "0.1.0" +dependencies = [ + "serde_json", + "tokio", + "vector-core", +] + +[[package]] +name = "vector-core" +version = "0.1.0" +dependencies = [ + "aes", + "aes-gcm", + "argon2", + "arti-client", + "async-trait", + "base64-simd", + "bip39", + "chacha20poly1305", + "fast-thumbhash", + "futures-util", + "hkdf", + "image", + "libc", + "nostr", + "nostr-blossom", + "nostr-connect", + "nostr-sdk", + "rand 0.8.6", + "reqwest", + "rusqlite", + "rustls", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-util", + "tor-circmgr", + "tor-dirmgr", + "tor-guardmgr", + "tor-linkspec", + "tor-rtcompat", + "url", + "zeroize", +] + +[[package]] +name = "vector_sdk" +version = "0.3.0" +dependencies = [ + "nostr-sdk", + "serde", + "serde_json", + "tokio", + "vector-core", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasix" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +dependencies = [ + "wasi", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +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.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "weak-table" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time-compat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39819265f219f60a92312f2755262dba9fff180a4ec281556863d69fa36adc59" +dependencies = [ + "web-time", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +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_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", + "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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +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.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.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.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.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.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.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.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "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", + "indexmap 2.14.0", + "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", + "indexmap 2.14.0", + "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.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/Cargo.toml b/crates/Cargo.toml new file mode 100644 index 00000000..a01e9076 --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +members = ["vector-core", "vector-cli", "vector-agent", "concord-cli", "vector-sdk"] +resolver = "2" + +[patch.crates-io] +# nostr SDK's SecretKey Drop uses non_secure_erase which the compiler can optimize away. +# Patched to use zeroize (volatile writes) so secret key bytes are guaranteed cleared from the stack. +nostr = { git = "https://github.com/VectorPrivacy/nostr.git", branch = "zeroize-secretkey" } diff --git a/crates/clippy.toml b/crates/clippy.toml new file mode 100644 index 00000000..8e9fc00d --- /dev/null +++ b/crates/clippy.toml @@ -0,0 +1,15 @@ +# Forbid raw reqwest::Client construction outside the canonical entry point. +# `vector_core::net::build_http_client` honors the Tor failsafe (proxy when +# Tor is up, blackhole when Tor is enabled-but-bootstrapping). Bypassing it +# can leak clearnet traffic when the user has Tor enabled. +disallowed-methods = [ + { path = "reqwest::Client::new", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies (or annotate this call site with #[allow(clippy::disallowed_methods)] if you genuinely need a raw client and have audited why it's safe)." }, + { path = "reqwest::Client::builder", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, + { path = "reqwest::ClientBuilder::new", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, + { path = "reqwest::get", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, + { path = "reqwest::Client::default", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, + { path = "reqwest::blocking::Client::new", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, + { path = "reqwest::blocking::Client::builder", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, + { path = "reqwest::blocking::ClientBuilder::new", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, + { path = "reqwest::blocking::get", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." }, +] diff --git a/crates/concord-cli/Cargo.toml b/crates/concord-cli/Cargo.toml new file mode 100644 index 00000000..e7b1bb6e --- /dev/null +++ b/crates/concord-cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "concord-cli" +version = "0.1.0" +edition = "2021" +description = "Diagnostic CLI to inspect Concord protocol state (epoch chains, rekeys, forks, keys) from a Vector account — local DB now, relay source next." + +[[bin]] +name = "concord" +path = "src/main.rs" + +[dependencies] +vector-core = { path = "../vector-core" } +tokio = { version = "1.49.0", features = ["full"] } +nostr-sdk = { version = "0.44.1", features = ["nip44", "nip59"] } diff --git a/crates/concord-cli/src/main.rs b/crates/concord-cli/src/main.rs new file mode 100644 index 00000000..5d89a071 --- /dev/null +++ b/crates/concord-cli/src/main.rs @@ -0,0 +1,627 @@ +//! `concord` — a diagnostic CLI for the Concord protocol: parse + render a community's epoch chains, +//! rekeys, edition heads, and (next increment) every known FORK, decrypted with a real account's keys — +//! so you can SEE the precise state instead of reverse-engineering obfuscated relay blobs. +//! +//! Source toggle: `--relay` (planned) reconstructs the chain by fetching + decrypting relay events under +//! every held server root; the DEFAULT reads the account's local decrypted DB. Both render identically. +//! +//! Usage: +//! VECTOR_NSEC=nsec1... [VECTOR_DATA_DIR=/path] [VECTOR_PASSWORD=...] concord [community_id_prefix] [--relay] +//! VECTOR_PASSWORD= VECTOR_DATA_DIR=/copy concord --invites [--from ] [--since-hours N] +//! +//! Auth: pass VECTOR_NSEC directly, OR omit it and pass VECTOR_PASSWORD — the key is then read from the +//! account DB and decrypted with the PIN (the sole `npub1…` subdir, or VECTOR_NPUB to disambiguate). +//! +//! `--invites` answers "is a direct invite actually on the network for this account?" — it fetches every +//! gift wrap addressed to me from my inbox relays (paged past the relay cap), unwraps each, and reports +//! which are COMMUNITY_INVITE_BUNDLE. +//! +//! Run against a COPY of the data dir — login writes a pkey row and purges the per-account mls store. + +use std::path::PathBuf; +use vector_core::community::SERVER_ROOT_SCOPE_HEX; +use vector_core::db::community as cdb; +use vector_core::{CoreConfig, VectorCore}; + +/// First 6 bytes of a 32-byte key as hex + ellipsis — enough to eyeball equality/divergence across accounts. +fn prefix(b: &[u8]) -> String { + let h: String = b.iter().take(6).map(|x| format!("{x:02x}")).collect(); + format!("{h}…") +} + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + let relay = args.iter().any(|a| a == "--relay"); + let editions = args.iter().any(|a| a == "--editions"); + let invites = args.iter().any(|a| a == "--invites"); + // `--from ` narrows the invite probe to one sender; `--since-hours N` widens the window + // (gift wraps backdate their outer timestamp up to ~2 days, so default generously). + let from = arg_value(&args, "--from"); + let since_arg = arg_value(&args, "--since-hours"); + let since_hours: u64 = since_arg.as_deref().and_then(|s| s.parse().ok()).unwrap_or(168); + // Positional community-id prefix: the first bare token that isn't a flag or a flag's value. + let consumed: Vec<&String> = [from.as_ref(), since_arg.as_ref()].into_iter().flatten().collect(); + let filter = args + .iter() + .skip(1) + .find(|a| !a.starts_with("--") && !consumed.contains(a)) + .cloned(); + + let mut nsec = std::env::var("VECTOR_NSEC").unwrap_or_default(); + let password = std::env::var("VECTOR_PASSWORD").ok(); + let data_dir = std::env::var("VECTOR_DATA_DIR").map(PathBuf::from).unwrap_or_else(|_| default_dir()); + + let core = VectorCore::init(CoreConfig { data_dir: data_dir.clone(), event_emitter: None }).unwrap_or_else(|e| { + eprintln!("init failed: {e}"); + std::process::exit(1); + }); + + // Password-from-DB login: no nsec given → read the encrypted pkey from the account DB and decrypt it + // with the PIN. Only the PIN is needed, never the raw nsec. The data dir MUST be a COPY — login writes + // a pkey row and purges the per-account mls store, so never point this at a live account dir. + if nsec.is_empty() { + let npub = resolve_account_npub(&data_dir).unwrap_or_else(|e| { + eprintln!("{e}"); + std::process::exit(1); + }); + vector_core::db::set_current_account(npub.clone()).and_then(|_| vector_core::db::init_database(&npub)).unwrap_or_else(|e| { + eprintln!("open db for {npub} failed: {e}"); + std::process::exit(1); + }); + let stored = vector_core::db::get_pkey().ok().flatten().unwrap_or_else(|| { + eprintln!("no stored key in {npub}/vector.db"); + std::process::exit(1); + }); + nsec = if stored.starts_with("nsec1") { + stored + } else { + let pin = password.clone().unwrap_or_else(|| { + eprintln!("account is encrypted — set VECTOR_PASSWORD="); + std::process::exit(1); + }); + vector_core::crypto::maybe_decrypt_inner(stored, Some(pin)).await.unwrap_or_else(|_| { + eprintln!("incorrect PIN (could not decrypt stored key)"); + std::process::exit(1); + }) + }; + } + + let acct = core.login(&nsec, password.as_deref()).await.unwrap_or_else(|e| { + eprintln!("login failed: {e}"); + std::process::exit(1); + }); + eprintln!("# account {} source={}", acct.npub, if relay { "local+relay" } else { "local" }); + + if invites { + probe_invites(since_hours, from.as_deref()).await; + std::process::exit(0); + } + + let ids = cdb::list_community_ids().unwrap_or_default(); + let mut shown = 0usize; + for id in ids { + let hex = id.to_hex(); + if let Some(f) = &filter { + if !hex.starts_with(f.as_str()) { + continue; + } + } + if let Ok(Some(c)) = cdb::load_community(&id) { + print_local(&c); + if relay { + print_relay(&c).await; + } + if editions { + print_editions(&c).await; + } + shown += 1; + } + } + if shown == 0 { + println!("(no matching communities)"); + } + // Exit hard: login may have spawned background relay tasks we don't need for a one-shot dump. + std::process::exit(0); +} + +fn print_local(c: &vector_core::community::Community) { + let hex = c.id.to_hex(); + let mode = match cdb::get_community_invite_registry(&hex) { + Ok(r) if !r.is_empty() => "PUBLIC", + _ => "private", + }; + println!("\n━━━━━━ {} ({}…) [{}]", c.name, &hex[..16], mode); + println!(" server-root: epoch {} key {}", c.server_root_epoch.0, prefix(c.server_root_key.as_bytes())); + + if let Ok(pending) = cdb::get_read_cut_pending(&hex) { + let target = cdb::get_read_cut_target_epoch(&hex).unwrap_or(0); + if pending || target != c.server_root_epoch.0 { + println!(" read-cut: pending={pending} target_epoch={target}"); + } + } + if let Ok(bl) = cdb::get_community_banlist(&hex) { + if !bl.is_empty() { + let who: Vec = bl.iter().map(|h| format!("{}…", &h[..h.len().min(10)])).collect(); + println!(" banlist: {}", who.join(", ")); + } + } + + // Base (server-root) epoch chain — one root per epoch; a future fork view shows siblings here. + if let Ok(mut roots) = cdb::held_epoch_keys(&hex, SERVER_ROOT_SCOPE_HEX) { + roots.sort_by_key(|(e, _)| e.0); + let chain: Vec = roots.iter().map(|(e, k)| format!("{}:{}", e.0, prefix(k))).collect(); + println!(" base chain: {}", chain.join(" → ")); + } + + for ch in &c.channels { + let chex = ch.id.to_hex(); + println!( + " ┌─ #{} ({}…) head epoch {} key {}", + ch.name, + &chex[..12], + ch.epoch.0, + prefix(ch.key.as_bytes()) + ); + if let Ok(mut ks) = cdb::held_epoch_keys(&hex, &chex) { + ks.sort_by_key(|(e, _)| e.0); + let chain: Vec = ks.iter().map(|(e, k)| format!("{}:{}", e.0, prefix(k))).collect(); + println!(" └─ epochs: {}", chain.join(" → ")); + } + } + + // Control-plane edition heads (refuse-downgrade floors): entity → epoch.version. + if let Ok(heads) = cdb::get_all_edition_heads_epoched(&hex) { + if !heads.is_empty() { + let mut hv: Vec<(&String, &(u64, u64, [u8; 32]))> = heads.iter().collect(); + hv.sort_by(|a, b| a.0.cmp(b.0)); + println!(" edition heads (entity → epoch.version):"); + for (entity, (epoch, version, _)) in hv { + let label = if entity == &hex { + "GroupRoot".to_string() + } else { + format!("{}…", &entity[..entity.len().min(12)]) + }; + println!(" {label:<16} {epoch}.{version}"); + } + } + } + + // Per-creator public invite links — the basis of the computed Public/Private mode. + if let Ok(sets) = cdb::get_invite_link_sets(&hex) { + for s in sets.iter().filter(|s| !s.locators.is_empty()) { + println!(" invite-links: {}… holds {}", &s.creator_hex[..s.creator_hex.len().min(10)], s.locators.len()); + } + } +} + +/// RELAY reconstruction: fetch the rekey events from the community's relays and decrypt each under EVERY +/// held server root, so we SEE every fork sibling at each epoch + whether THIS account is a recipient of +/// each (the exact inputs the convergence heal works from). Uses local keys to decrypt; events from relays. +async fn print_relay(c: &vector_core::community::Community) { + use vector_core::community::derive::{self, RekeyScope}; + use vector_core::community::rekey; + use vector_core::community::transport::{LiveTransport, Query, Transport}; + use vector_core::community::{Epoch, ServerRootKey}; + use vector_core::stored_event::event_kind::COMMUNITY_REKEY; + use std::collections::{BTreeMap, HashSet}; + + let hex = c.id.to_hex(); + println!("\n ── RELAY reconstruction ──"); + let Some(keys) = vector_core::state::MY_SECRET_KEY.to_keys() else { + println!(" (no local key; cannot decrypt)"); + return; + }; + let sk = keys.secret_key(); + let tx = LiveTransport::with_timeout(std::time::Duration::from_secs(15)); + + // Per-relay latency, split into the two costs that matter on a high-latency link, measured with a FRESH + // throwaway client per relay so the handshake is real (not hidden behind an already-open pool socket): + // • socket-connect = TCP + TLS + WebSocket upgrade (the cost paid on every cold start / reconnect) + // • in-tunnel fetch = REQ → response once the socket is up (what the warmed pool would show) + // A re-founding's coverage gate fetches over the shared pool, so a relay that's cheap in-tunnel but + // expensive to (re)connect can still blow the budget after a reconnect — hence reporting both. + { + use nostr_sdk::{Client, Filter, Kind}; + use std::time::{Duration, Instant}; + use vector_core::stored_event::event_kind::COMMUNITY_CONTROL; + println!(" ── per-relay latency (fresh connection) ──"); + for r in &c.relays { + let client = Client::default(); + let _ = client.add_relay(r.as_str()).await; + let t0 = Instant::now(); + client.try_connect(Duration::from_secs(15)).await; + let connect_ms = t0.elapsed().as_millis(); + let filter = Filter::new().kind(Kind::Custom(COMMUNITY_CONTROL)).limit(1); + let t1 = Instant::now(); + let res = client.fetch_events_from(vec![r.clone()], filter, Duration::from_secs(15)).await; + let fetch_ms = t1.elapsed().as_millis(); + let n = res.as_ref().map(|e| e.len()).unwrap_or(0); + println!(" {r}\n socket-connect {connect_ms:>6} ms in-tunnel fetch {fetch_ms:>6} ms ({n} ev)"); + let _ = client.shutdown().await; + } + } + + let roots = vector_core::db::community::held_epoch_keys(&hex, vector_core::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default(); + if roots.is_empty() { + println!(" (no held server roots)"); + return; + } + let cur_base = c.server_root_epoch.0; + + // BASE re-foundings: a base rekey to epoch e is addressed under the PRIOR root (e-1), so try each held + // root as a prior. ≥2 distinct delivered roots at one epoch = a re-founding fork (B2). + println!(" BASE (fork = ≥2 roots at one epoch):"); + let mut base: BTreeMap)>> = BTreeMap::new(); + for (pe, prior) in &roots { + let target = pe.0 + 1; + let z = derive::base_rekey_pseudonym(&ServerRootKey(*prior), &c.id, Epoch(target)).to_hex(); + let q = Query { kinds: vec![COMMUNITY_REKEY], z_tags: vec![z], since: None, ..Default::default() }; + for ev in tx.fetch(&q, &c.relays).await.unwrap_or_default() { + if let Ok(p) = rekey::open_rekey_event(&ev, prior) { + if matches!(p.scope, RekeyScope::ServerRoot) && p.new_epoch.0 == target { + base.entry(target).or_default().push((p.rotator.to_bytes(), peek_key(sk, &p))); + } + } + } + } + if base.is_empty() { + println!(" (none found on relays)"); + } + for (epoch, sibs) in &base { + let distinct: HashSet<_> = sibs.iter().filter_map(|(_, m)| *m).collect(); + let tag = if distinct.len() >= 2 { " <<< FORK" } else { "" }; + let star = if *epoch == cur_base { " (current)" } else { "" }; + println!(" epoch {epoch}{star}: {} candidate(s), {} distinct root(s){tag}", sibs.len(), distinct.len()); + for (rot, mine) in sibs { + let m = mine.map(|k| prefix(&k)).unwrap_or_else(|| "NOT A RECIPIENT".into()); + println!(" rotator {} root→me {m}", hexpref(rot)); + } + // VERDICT: a correct client adopts the LOWEST root (deterministic B2 tiebreak). If my head root + // disagrees with this, that's the bug, in one glance. + if distinct.len() >= 2 { + if let Some(win) = distinct.iter().min() { + println!(" → verdict: converge to LOWEST root {} (B2 tiebreak)", prefix(win)); + } + } + } + + // CHANNEL rekeys: a re-founding rekeys the channel under the (shared) root current at publish; search + // EVERY held root. ≥2 distinct delivered keys at one epoch = the A-B2 channel fork. + for ch in &c.channels { + let head = ch.epoch.0; + println!(" #{} (fork = ≥2 keys at one epoch):", ch.name); + let mut by_epoch: BTreeMap)>> = BTreeMap::new(); + for (re, root) in &roots { + let z_tags: Vec = (1..=head + 1) + .map(|e| derive::rekey_pseudonym(&ServerRootKey(*root), &ch.id, Epoch(e)).to_hex()) + .collect(); + let q = Query { kinds: vec![COMMUNITY_REKEY], z_tags, since: None, ..Default::default() }; + for ev in tx.fetch(&q, &c.relays).await.unwrap_or_default() { + if let Ok(p) = rekey::open_rekey_event(&ev, root) { + if matches!(p.scope, RekeyScope::Channel(id) if id == ch.id) { + by_epoch.entry(p.new_epoch.0).or_default().push((re.0, p.rotator.to_bytes(), peek_key(sk, &p))); + } + } + } + } + if by_epoch.is_empty() { + println!(" (no channel rekeys found on relays)"); + } + for (epoch, sibs) in &by_epoch { + let distinct: HashSet<_> = sibs.iter().filter_map(|(_, _, m)| *m).collect(); + let tag = if distinct.len() >= 2 { " <<< FORK (channel diverged)" } else { "" }; + let star = if *epoch == head { " (current head)" } else { "" }; + println!(" epoch {epoch}{star}: {} candidate(s), {} distinct key(s){tag}", sibs.len(), distinct.len()); + for (root_ep, rot, mine) in sibs { + let m = mine.map(|k| prefix(&k)).unwrap_or_else(|| "NOT A RECIPIENT".into()); + println!(" under root@{root_ep} rotator {} key→me {m}", hexpref(rot)); + } + // VERDICT: a correct client adopts the LOWEST key (A-B2 tiebreak). If my channel head disagrees, + // that's the bug. NOT A RECIPIENT on the winning sibling = I can't converge (a retain-set gap). + if distinct.len() >= 2 { + if let Some(win) = distinct.iter().min() { + println!(" → verdict: converge to LOWEST key {} (A-B2 tiebreak)", prefix(win)); + } + } + } + } + + // BY-AUTHOR census: a BROAD fetch of every rekey on the relays (no #z filter), grouped by who rotated. + // Each is tried under every held root; what opens shows that author's published rekeys, what DOESN'T is + // counted as OPAQUE (under a root I don't hold) — so "the winner never published a channel rekey" vs + // "published one under a root I dropped" is answerable in one glance. + { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let q = Query { kinds: vec![COMMUNITY_REKEY], z_tags: vec![], since: Some(now.saturating_sub(48 * 3600)), ..Default::default() }; + let events = tx.fetch(&q, &c.relays).await.unwrap_or_default(); + println!("\n ── by-author census ({} rekey events on relays, last 48h) ──", events.len()); + let mut by_author: BTreeMap)>> = BTreeMap::new(); + let mut opaque: BTreeMap = BTreeMap::new(); + for ev in &events { + let mut opened = false; + for (re, root) in &roots { + if let Ok(p) = rekey::open_rekey_event(ev, root) { + let scope = match p.scope { + RekeyScope::ServerRoot => "base".to_string(), + RekeyScope::Channel(id) => format!("chan:{}", &id.to_hex()[..6]), + }; + let key: String = p.rotator.to_bytes().iter().map(|b| format!("{b:02x}")).collect(); + by_author.entry(key).or_default().push((scope, p.new_epoch.0, re.0, peek_key(sk, &p))); + opened = true; + break; + } + } + if !opened { + let z = ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "z").then(|| s[1].clone()) + }).unwrap_or_default(); + *opaque.entry(z.chars().take(12).collect()).or_default() += 1; + } + } + for (rot, mut items) in by_author { + items.sort_by_key(|(_, e, _, _)| *e); + println!(" rotator {}…", &rot[..rot.len().min(10)]); + for (scope, epoch, root_ep, mine) in items { + let m = mine.map(|k| prefix(&k)).unwrap_or_else(|| "not-to-me".into()); + println!(" {scope} epoch {epoch} under root@{root_ep} key→me {m}"); + } + } + if !opaque.is_empty() { + let total: usize = opaque.values().sum(); + println!(" {total} OPAQUE event(s) under roots I don't hold (publisher's root unknown to me):"); + for (z, n) in opaque { + println!(" #z {z}… ×{n}"); + } + } + } +} + +/// Open MY blob in a rekey (the key it delivers to this account), or None if I'm not a recipient. +fn peek_key(sk: &nostr_sdk::SecretKey, p: &vector_core::community::rekey::ParsedRekey) -> Option<[u8; 32]> { + use vector_core::community::{derive, rekey}; + let secret = rekey::rekey_pairwise_secret(sk, &p.rotator).ok()?; + let loc = derive::recipient_pseudonym(&secret, p.scope, p.new_epoch).to_hex(); + let blob = p.blobs.iter().find(|b| b.locator == loc)?; + rekey::open_rekey_blob(sk, &p.rotator, p.scope, p.new_epoch, blob).ok() +} + +/// Short hex of a pubkey/id (first 5 bytes). +fn hexpref(b: &[u8]) -> String { + let h: String = b.iter().take(5).map(|x| format!("{x:02x}")).collect(); + format!("{h}…") +} + +/// Value of a `--flag value` pair on the command line, if present. +/// Census of CONTROL EDITIONS (kind 3308) per relay at the community's CURRENT-epoch control +/// coordinate — the exact data the metadata/roles fold consumes. Attributes a "seat stuck at an +/// old name": relay starvation (newer editions missing from every reachable relay) vs +/// gap-quarantine (head present but a chain prerequisite missing). +async fn print_editions(c: &vector_core::community::Community) { + use nostr_sdk::{Alphabet, Client, Filter, Kind, SingleLetterTag}; + use std::time::Duration; + use vector_core::stored_event::event_kind::COMMUNITY_CONTROL; + + let z = vector_core::community::roster::control_pseudonym(&c.server_root_key, &c.id, c.server_root_epoch); + println!("\n ── CONTROL EDITIONS per relay (epoch {} coordinate) ──", c.server_root_epoch.0); + for r in &c.relays { + let client = Client::default(); + let _ = client.add_relay(r.as_str()).await; + client.try_connect(Duration::from_secs(15)).await; + let filter = Filter::new() + .kind(Kind::Custom(COMMUNITY_CONTROL)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::Z), vec![z.clone()]) + .limit(250); + let res = client.fetch_events_from(vec![r.clone()], filter, Duration::from_secs(15)).await; + match res { + Ok(events) => { + println!(" {r} ({} events)", events.len()); + let mut rows: Vec<(u64, String)> = Vec::new(); + for outer in events.into_iter() { + match vector_core::community::roster::open_control_edition(&outer, &c.server_root_key) { + Ok(inner) => { + let head: String = inner.content.chars().take(110).collect(); + rows.push(( + inner.created_at.as_secs(), + format!(" {} by {} {}", inner.created_at.as_secs(), &inner.pubkey.to_hex()[..8], head), + )); + } + Err(_) => rows.push((0, format!(" (undecryptable outer {})", &outer.id.to_hex()[..8]))), + } + } + rows.sort(); + for (_, line) in rows { + println!("{line}"); + } + } + Err(e) => println!(" {r} FETCH FAILED: {e}"), + } + let _ = client.shutdown().await; + } +} + +fn arg_value(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() +} + +/// Resolve which account to open for password-from-DB login: `VECTOR_NPUB` if set, else the sole +/// `npub1…` subdir under the data dir. Errors (listing options) if it's ambiguous, so we never guess. +fn resolve_account_npub(data_dir: &std::path::Path) -> Result { + if let Ok(n) = std::env::var("VECTOR_NPUB") { + if !n.is_empty() { + return Ok(n); + } + } + let npubs: Vec = std::fs::read_dir(data_dir) + .map_err(|e| format!("cannot read {}: {e}", data_dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| n.starts_with("npub1")) + .collect(); + match npubs.len() { + 1 => Ok(npubs.into_iter().next().unwrap()), + 0 => Err(format!("no npub1… account dir under {}", data_dir.display())), + _ => Err(format!("multiple accounts under {} — set VECTOR_NPUB to one of:\n {}", data_dir.display(), npubs.join("\n "))), + } +} + +/// INVITE PROBE: is a direct community invite actually on the network for THIS account? +/// +/// Fetches every kind-1059 gift wrap addressed to me from my own inbox relays (kind 10050) plus the +/// trusted relays, per-relay so a "delivered to relay A, missing on relay B" split is visible, unwraps +/// each with my key, and reports which inner rumors are COMMUNITY_INVITE_BUNDLE (3304) — with sender, +/// community, channel count and the rumor's own timestamp (the real send time; the wrap backdates). +async fn probe_invites(since_hours: u64, from: Option<&str>) { + use nostr_sdk::{Filter, Kind, RelayUrl, Timestamp, ToBech32}; + use std::collections::{BTreeMap, HashSet}; + use std::time::Duration; + use vector_core::stored_event::event_kind::COMMUNITY_INVITE_BUNDLE; + + let Some(me) = vector_core::state::my_public_key() else { + println!("(no active pubkey)"); + return; + }; + let Some(keys) = vector_core::state::MY_SECRET_KEY.to_keys() else { + println!("(no local key; cannot unwrap)"); + return; + }; + let Some(client) = vector_core::state::nostr_client() else { + println!("(no nostr client)"); + return; + }; + + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let since = Timestamp::from(now.saturating_sub(since_hours.saturating_mul(3600))); + let from_pk = from.and_then(|s| { + nostr_sdk::PublicKey::parse(s).map_err(|e| eprintln!("bad --from {s}: {e}")).ok() + }); + + // Relay set: my published inbox relays (where invites are delivered) ∪ trusted relays. + let mut relays: Vec = vector_core::inbox_relays::trusted_relay_urls(); + { + let f = Filter::new().author(me).kind(Kind::Custom(10050)).limit(1); + if let Ok(evs) = client.fetch_events(f, Duration::from_secs(8)).await { + if let Some(ev) = evs.into_iter().next() { + for t in ev.tags.iter() { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "relay" { + if let Ok(u) = RelayUrl::parse(&s[1]) { + if !relays.contains(&u) { + relays.push(u); + } + } + } + } + } + } + } + println!( + "\n ── invite probe ──\n me {}\n window last {since_hours}h relays {}{}", + me.to_bech32().unwrap_or_default(), + relays.len(), + from_pk.map(|p| format!(" from {}", &p.to_hex()[..16])).unwrap_or_default() + ); + + // Per-relay fetch so a delivery split is visible; union the gift wraps by id for unwrapping. + // Relays cap a single response (~500), so page backwards by `until` until the window is exhausted — + // otherwise a truncated relay could hide the very invite we're hunting (no silent caps). + let mut union: BTreeMap = BTreeMap::new(); + for r in &relays { + let _ = client.add_relay(r.as_str()).await; + let mut until: Option = None; + let mut got = 0usize; + let mut pages = 0u32; + loop { + let mut f = Filter::new().kind(Kind::GiftWrap).pubkey(me).since(since).limit(500); + if let Some(u) = until { + f = f.until(u); + } + let evs = client.fetch_events_from(vec![r.clone()], f, Duration::from_secs(12)).await.unwrap_or_default(); + let n = evs.len(); + // Oldest in this page seeds the next page's `until` (one second before, to avoid re-fetching it). + let oldest = evs.iter().map(|e| e.created_at).min(); + for ev in evs.into_iter() { + union.insert(ev.id, ev); + } + got += n; + pages += 1; + // Stop when the relay returns a short page (no more), or we'd loop forever, or we've paged deep. + match oldest { + Some(o) if n >= 500 && o > since && pages < 40 => until = Some(Timestamp::from(o.as_secs().saturating_sub(1))), + _ => break, + } + } + println!(" {r} → {got} gift wrap(s) ({pages} page(s))"); + } + println!(" unique gift wraps: {}", union.len()); + + // Unwrap each, tally inner kinds, detail every invite bundle. + let mut by_kind: BTreeMap = BTreeMap::new(); + let mut undecryptable = 0usize; + let mut invites_found: Vec<(nostr_sdk::PublicKey, u64, vector_core::community::invite::CommunityInvite)> = Vec::new(); + let mut seen_senders: HashSet = HashSet::new(); + for ev in union.values() { + match nostr_sdk::nips::nip59::UnwrappedGift::from_gift_wrap(&keys, ev).await { + Ok(g) => { + let k = g.rumor.kind.as_u16(); + *by_kind.entry(k).or_default() += 1; + seen_senders.insert(g.sender); + if k == COMMUNITY_INVITE_BUNDLE { + if let Some(inv) = vector_core::community::invite::parse_invite_rumor(g.rumor.kind, &g.rumor.content) { + if from_pk.map(|p| p == g.sender).unwrap_or(true) { + invites_found.push((g.sender, g.rumor.created_at.as_secs(), inv)); + } + } + } + } + Err(_) => undecryptable += 1, + } + } + + println!("\n inner-kind tally (unwrapped):"); + for (k, n) in &by_kind { + let label = match *k { + 14 => " (nip17 dm)", + 15 => " (nip17 file)", + COMMUNITY_INVITE_BUNDLE => " (COMMUNITY INVITE)", + _ => "", + }; + println!(" kind {k:<5} ×{n}{label}"); + } + if undecryptable > 0 { + println!(" ({undecryptable} wrap(s) not addressed to / not decryptable by me)"); + } + println!(" distinct senders seen: {}", seen_senders.len()); + + println!("\n ── community invites on network: {} ──", invites_found.len()); + if invites_found.is_empty() { + println!(" NONE. No COMMUNITY_INVITE_BUNDLE gift wrap for this account on these relays in-window."); + println!(" → the invite never reached these relays (sender-side / relay-mismatch), OR it is older than {since_hours}h."); + } + invites_found.sort_by_key(|(_, ts, _)| *ts); + for (sender, ts, inv) in &invites_found { + println!( + " • '{}' ({}…)\n from {}\n sent {} relays {} channels {}", + inv.name, + &inv.community_id[..inv.community_id.len().min(16)], + sender.to_bech32().unwrap_or_else(|_| sender.to_hex()), + ts, + inv.relays.len(), + inv.channels.len(), + ); + } +} + +fn default_dir() -> PathBuf { + #[cfg(target_os = "macos")] + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join("Library/Application Support/io.vectorapp/agent"); + } + #[cfg(target_os = "linux")] + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".local/share/io.vectorapp/agent"); + } + PathBuf::from("/tmp/vector-data") +} diff --git a/crates/vector-agent/Cargo.toml b/crates/vector-agent/Cargo.toml new file mode 100644 index 00000000..0db5152b --- /dev/null +++ b/crates/vector-agent/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "vector-agent" +version = "0.1.0" +edition = "2021" +description = "MCP server giving AI agents full Vector/Nostr capabilities via vector-core." + +[dependencies] +vector-core = { path = "../vector-core" } +rmcp = { version = "1.5", features = ["server", "transport-io"] } +tokio = { version = "1.49.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +schemars = "1.0" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/vector-agent/src/handler.rs b/crates/vector-agent/src/handler.rs new file mode 100644 index 00000000..7e222d13 --- /dev/null +++ b/crates/vector-agent/src/handler.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; +use tokio::sync::Mutex; +use vector_core::{InboundEventHandler, Message}; + +#[derive(Clone, Debug, serde::Serialize)] +pub struct BufferedMessage { + pub chat_id: String, + pub is_group: bool, + #[serde(flatten)] + pub message: Message, +} + +pub struct AgentEventHandler { + buffer: Arc>>, +} + +impl AgentEventHandler { + pub fn new() -> (Self, Arc>>) { + let buffer = Arc::new(Mutex::new(Vec::new())); + (Self { buffer: buffer.clone() }, buffer) + } + + /// Build a handler that writes into an EXISTING buffer — used when re-attaching `listen()` + /// after an account swap, so the new session's events flow into the same buffer the MCP + /// `get_new_messages` tool already reads from. + pub fn with_buffer(buffer: Arc>>) -> Self { + Self { buffer } + } +} + +impl InboundEventHandler for AgentEventHandler { + fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) { + let entry = BufferedMessage { + chat_id: chat_id.to_string(), + is_group: false, + message: msg.clone(), + }; + let buf = self.buffer.clone(); + tokio::spawn(async move { + buf.lock().await.push(entry); + }); + } + + fn on_file_received(&self, chat_id: &str, msg: &Message, _is_new: bool) { + let entry = BufferedMessage { + chat_id: chat_id.to_string(), + is_group: false, + message: msg.clone(), + }; + let buf = self.buffer.clone(); + tokio::spawn(async move { + buf.lock().await.push(entry); + }); + } +} diff --git a/crates/vector-agent/src/main.rs b/crates/vector-agent/src/main.rs new file mode 100644 index 00000000..204e009a --- /dev/null +++ b/crates/vector-agent/src/main.rs @@ -0,0 +1,105 @@ +mod handler; +mod tools; + +use std::path::PathBuf; +use std::sync::Arc; +use rmcp::{ServiceExt, transport::stdio}; +use vector_core::{VectorCore, CoreConfig}; + +use handler::AgentEventHandler; +use tools::VectorAgent; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + ) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + + let nsec = match std::env::var("VECTOR_NSEC") { + Ok(v) if !v.is_empty() => v, + _ => { + eprintln!("Error: VECTOR_NSEC environment variable is required"); + eprintln!("Usage: VECTOR_NSEC=nsec1... vector-agent"); + std::process::exit(1); + } + }; + + let data_dir = std::env::var("VECTOR_DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| dirs_or_default()); + + std::fs::create_dir_all(&data_dir).ok(); + + let core = VectorCore::init(CoreConfig { + data_dir, + event_emitter: None, + }).unwrap_or_else(|e| { + eprintln!("Failed to initialize Vector Core: {}", e); + std::process::exit(1); + }); + + let password = std::env::var("VECTOR_PASSWORD").ok(); + match core.login(&nsec, password.as_deref()).await { + Ok(result) => { + eprintln!("[vector-agent] Logged in as {}", result.npub); + } + Err(e) => { + eprintln!("Login failed: {}", e); + std::process::exit(1); + } + } + + // Wait for relay connections + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // Start background listener with event handler + let (event_handler, message_buffer) = AgentEventHandler::new(); + let listen_core = VectorCore; + tokio::spawn(async move { + if let Err(e) = listen_core.listen(Arc::new(event_handler)).await { + eprintln!("[vector-agent] Listen error: {}", e); + } + }); + + eprintln!("[vector-agent] MCP server ready (stdio)"); + + let agent = VectorAgent::new(core, message_buffer); + let service = agent.serve(stdio()).await.unwrap_or_else(|e| { + eprintln!("Failed to start MCP server: {}", e); + std::process::exit(1); + }); + + service.waiting().await.unwrap_or_else(|e| { + eprintln!("MCP server error: {}", e); + std::process::exit(1); + }); +} + +fn dirs_or_default() -> PathBuf { + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join("Library/Application Support/io.vectorapp/agent"); + } + } + #[cfg(target_os = "linux")] + { + if let Ok(data) = std::env::var("XDG_DATA_HOME") { + return PathBuf::from(data).join("io.vectorapp/agent"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".local/share/io.vectorapp/agent"); + } + } + #[cfg(target_os = "windows")] + { + if let Ok(appdata) = std::env::var("APPDATA") { + return PathBuf::from(appdata).join("io.vectorapp/agent"); + } + } + PathBuf::from("/tmp/vector-data") +} diff --git a/crates/vector-agent/src/tools.rs b/crates/vector-agent/src/tools.rs new file mode 100644 index 00000000..510227df --- /dev/null +++ b/crates/vector-agent/src/tools.rs @@ -0,0 +1,625 @@ +use std::sync::Arc; +use tokio::sync::Mutex; +use rmcp::{ + ServerHandler, + handler::server::router::tool::ToolRouter, + handler::server::wrapper::Parameters, + model::*, + schemars, tool, tool_handler, tool_router, + ErrorData as McpError, +}; +use vector_core::VectorCore; + +use crate::handler::BufferedMessage; + +// ============================================================================ +// Request types — each becomes JSON Schema via schemars +// ============================================================================ + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SendDmRequest { + #[schemars(description = "Recipient's npub (e.g. npub1abc...)")] + pub to_npub: String, + #[schemars(description = "Message content")] + pub content: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SendFileRequest { + #[schemars(description = "Recipient's npub")] + pub to_npub: String, + #[schemars(description = "Absolute path to the file to send")] + pub file_path: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GetMessagesRequest { + #[schemars(description = "Chat ID (npub for DMs, group_id for groups)")] + pub chat_id: String, + #[schemars(description = "Maximum number of messages to return")] + #[serde(default = "default_limit")] + pub limit: usize, + #[schemars(description = "Offset from most recent")] + #[serde(default)] + pub offset: usize, +} + +fn default_limit() -> usize { 50 } + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct NpubRequest { + #[schemars(description = "User's npub")] + pub npub: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AddAccountRequest { + #[schemars(description = "Optional nsec to import. OMIT to generate a fresh, random identity (preferred for test accounts — keeps secret keys out of the conversation).")] + #[serde(default)] + pub nsec: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SetNicknameRequest { + #[schemars(description = "User's npub")] + pub npub: String, + #[schemars(description = "Nickname to set")] + pub nickname: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SyncDmsRequest { + #[schemars(description = "Number of days to sync (e.g. 7 for last week). Omit for full history sync.")] + pub since_days: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct UpdateProfileRequest { + #[schemars(description = "Display name")] + pub name: String, + #[schemars(description = "Avatar URL")] + #[serde(default)] + pub avatar: String, + #[schemars(description = "Banner URL")] + #[serde(default)] + pub banner: String, + #[schemars(description = "About/bio text")] + #[serde(default)] + pub about: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct JoinCommunityRequest { + #[schemars(description = "Public invite URL (e.g. https://vectorapp.io/invite#...)")] + pub invite_url: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CommunityIdRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RevokePublicInviteRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, + #[schemars(description = "Hex token of the invite link to revoke (from list_public_invites)")] + pub token: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CreateCommunityRequest { + #[schemars(description = "Name for the new community")] + pub name: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CommunityMemberRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, + #[schemars(description = "The target member's npub")] + pub npub: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct EditCommunityMetadataRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, + #[schemars(description = "New name (omit to leave unchanged)")] + #[serde(default)] + pub name: Option, + #[schemars(description = "New description (empty string clears it; omit to leave unchanged)")] + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SendCommunityMessageRequest { + #[schemars(description = "Channel id (the hex chat id of a Community channel)")] + pub channel_id: String, + #[schemars(description = "Message content")] + pub content: String, + #[schemars(description = "Optional inner id of a message to reply to")] + #[serde(default)] + pub replied_to: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SyncCommunityChannelRequest { + #[schemars(description = "Channel id to sync the latest page of")] + pub channel_id: String, + #[schemars(description = "Max messages to fetch (default 20)")] + #[serde(default = "default_page")] + pub limit: usize, +} + +fn default_page() -> usize { 20 } + +// ============================================================================ +// VectorAgent — MCP server with all tools +// ============================================================================ + +#[derive(Clone)] +pub struct VectorAgent { + core: VectorCore, + message_buffer: Arc>>, + #[allow(dead_code)] + tool_router: ToolRouter, +} + +#[tool_router] +impl VectorAgent { + pub fn new(core: VectorCore, message_buffer: Arc>>) -> Self { + Self { + core, + message_buffer, + tool_router: Self::tool_router(), + } + } + + // === Identity === + + #[tool(description = "Get the current user's npub (Nostr public key in bech32 format)")] + async fn my_npub(&self) -> Result { + match self.core.my_npub() { + Some(npub) => Ok(CallToolResult::success(vec![Content::text(npub)])), + None => Ok(CallToolResult::error(vec![Content::text("Not logged in")])), + } + } + + // === Accounts (multi-account, GUI-parity) === + + /// Re-attach the background DM listener to the CURRENT session, writing into the shared buffer + /// that `get_new_messages` drains. Called after a swap once the new client is connected; the + /// prior listener ended when `swap_session` shut its client down. + fn respawn_listener(&self) { + let buffer = self.message_buffer.clone(); + tokio::spawn(async move { + let handler = Arc::new(crate::handler::AgentEventHandler::with_buffer(buffer)); + if let Err(e) = VectorCore.listen(handler).await { + eprintln!("[vector-agent] re-listen error: {}", e); + } + }); + } + + #[tool(description = "List the Vector accounts this agent holds locally (each a separate npub with its own store). The active account is flagged. Use swap_account to switch between them.")] + async fn list_accounts(&self) -> Result { + let current = self.core.my_npub(); + match vector_core::db::get_accounts() { + Ok(accts) => { + let list: Vec<_> = accts.into_iter().map(|npub| { + let active = current.as_deref() == Some(npub.as_str()); + serde_json::json!({ "npub": npub, "active": active }) + }).collect(); + let json = serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])), + } + } + + #[tool(description = "The npub of the currently active account.")] + async fn current_account(&self) -> Result { + match self.core.my_npub() { + Some(npub) => Ok(CallToolResult::success(vec![Content::text(npub)])), + None => Ok(CallToolResult::error(vec![Content::text("No active account")])), + } + } + + #[tool(description = "Add a Vector account and switch to it. With NO nsec, generates a fresh random identity (preferred for test accounts). With an nsec, imports it. Returns the new account's npub.")] + async fn add_account(&self, Parameters(req): Parameters) -> Result { + let nsec = match req.nsec.as_deref() { + Some(s) if !s.is_empty() => s.to_string(), + _ => match self.core.generate_nsec() { + Ok(n) => n, + Err(e) => return Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + }, + }; + self.core.swap_session().await; + { self.message_buffer.lock().await.clear(); } + match self.core.login(&nsec, None).await { + Ok(res) => { + self.respawn_listener(); + Ok(CallToolResult::success(vec![Content::text(format!("Added and switched to {}", res.npub))])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Switch the active account to one already held locally (by npub). Loads that account's stored key — no secret key is passed or exposed. See list_accounts for options.")] + async fn swap_account(&self, Parameters(req): Parameters) -> Result { + let accts = vector_core::db::get_accounts().unwrap_or_default(); + if !accts.iter().any(|a| a == &req.npub) { + return Ok(CallToolResult::error(vec![Content::text( + format!("No local account {}. Use list_accounts or add_account first.", req.npub))])); + } + if self.core.my_npub().as_deref() == Some(req.npub.as_str()) { + return Ok(CallToolResult::success(vec![Content::text(format!("Already active: {}", req.npub))])); + } + self.core.swap_session().await; + // Open the target account's store to read its stored key, then bind it via the normal login path. + if let Err(e) = vector_core::db::set_current_account(req.npub.clone()) { + return Ok(CallToolResult::error(vec![Content::text(e)])); + } + if let Err(e) = vector_core::db::init_database(&req.npub) { + return Ok(CallToolResult::error(vec![Content::text(e)])); + } + let nsec = match vector_core::db::get_pkey() { + Ok(Some(n)) => n, + Ok(None) => return Ok(CallToolResult::error(vec![Content::text( + "That account has no stored key (encrypted or external signer) — can't swap to it headlessly.")])), + Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])), + }; + { self.message_buffer.lock().await.clear(); } + match self.core.login(&nsec, None).await { + Ok(res) => { + self.respawn_listener(); + Ok(CallToolResult::success(vec![Content::text(format!("Switched to {}", res.npub))])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + // === Messaging === + + #[tool(description = "Send an encrypted direct message (NIP-17 gift-wrapped DM) to a Nostr user")] + async fn send_dm(&self, Parameters(req): Parameters) -> Result { + match self.core.send_dm(&req.to_npub, &req.content).await { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + format!("DM sent to {}", &req.to_npub[..20.min(req.to_npub.len())]) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Send an encrypted file attachment via DM to a Nostr user")] + async fn send_file(&self, Parameters(req): Parameters) -> Result { + match self.core.send_file(&req.to_npub, &req.file_path).await { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + format!("File sent to {}", &req.to_npub[..20.min(req.to_npub.len())]) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Get message history for a chat. Returns messages in chronological order.")] + async fn get_messages(&self, Parameters(req): Parameters) -> Result { + let msgs = self.core.get_messages(&req.chat_id, req.limit, req.offset).await; + let json = serde_json::to_string_pretty(&msgs).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + #[tool(description = "Get all new messages received since the last call. Returns and clears the incoming message buffer. Use this to poll for new DMs and group messages.")] + async fn get_new_messages(&self) -> Result { + let messages: Vec = { + let mut buf = self.message_buffer.lock().await; + buf.drain(..).collect() + }; + let count = messages.len(); + let json = serde_json::to_string_pretty(&messages).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text( + if count == 0 { "No new messages".into() } else { json } + )])) + } + + #[tool(description = "List all chats (DMs and groups) with their latest message")] + async fn list_chats(&self) -> Result { + let chats = self.core.get_chats().await; + let json = serde_json::to_string_pretty(&chats).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + #[tool(description = "Sync DM history from relays using NIP-77 negentropy reconciliation. Fetches missed messages and populates chat history. Use since_days to limit scope (e.g. 7 for last week) or omit for full sync.")] + async fn sync_dms(&self, Parameters(req): Parameters) -> Result { + match self.core.sync_dms(req.since_days, &vector_core::NoOpEventHandler).await { + Ok((events, new)) => Ok(CallToolResult::success(vec![Content::text( + format!("DM sync complete: {} events processed, {} new messages", events, new) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + // === Profiles === + + #[tool(description = "Get a user's profile by npub (from local cache)")] + async fn get_profile(&self, Parameters(req): Parameters) -> Result { + match self.core.get_profile(&req.npub).await { + Some(profile) => { + let json = serde_json::to_string_pretty(&profile).unwrap_or_else(|_| "{}".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + None => Ok(CallToolResult::error(vec![Content::text("Profile not found in cache. Use load_profile to fetch from relays.")])), + } + } + + #[tool(description = "Fetch a user's profile metadata from Nostr relays (updates local cache)")] + async fn load_profile(&self, Parameters(req): Parameters) -> Result { + if self.core.load_profile(&req.npub).await { + match self.core.get_profile(&req.npub).await { + Some(profile) => { + let json = serde_json::to_string_pretty(&profile).unwrap_or_else(|_| "{}".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + None => Ok(CallToolResult::success(vec![Content::text("Profile fetched but not cached")])), + } + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to fetch profile from relays")])) + } + } + + #[tool(description = "Update the current user's profile (name, avatar URL, banner URL, about/bio)")] + async fn update_profile(&self, Parameters(req): Parameters) -> Result { + if self.core.update_profile(&req.name, &req.avatar, &req.banner, &req.about).await { + Ok(CallToolResult::success(vec![Content::text("Profile updated")])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to update profile")])) + } + } + + #[tool(description = "Block a user by npub")] + async fn block_user(&self, Parameters(req): Parameters) -> Result { + if self.core.block_user(&req.npub).await { + Ok(CallToolResult::success(vec![Content::text(format!("Blocked {}", &req.npub[..20.min(req.npub.len())]))])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to block user")])) + } + } + + #[tool(description = "Unblock a user by npub")] + async fn unblock_user(&self, Parameters(req): Parameters) -> Result { + if self.core.unblock_user(&req.npub).await { + Ok(CallToolResult::success(vec![Content::text(format!("Unblocked {}", &req.npub[..20.min(req.npub.len())]))])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to unblock user")])) + } + } + + #[tool(description = "Set a local nickname for a user (only visible to you)")] + async fn set_nickname(&self, Parameters(req): Parameters) -> Result { + if self.core.set_nickname(&req.npub, &req.nickname).await { + Ok(CallToolResult::success(vec![Content::text(format!("Nickname set to '{}'", req.nickname))])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to set nickname")])) + } + } + + #[tool(description = "Get all blocked user profiles")] + async fn get_blocked_users(&self) -> Result { + let blocked = self.core.get_blocked_users().await; + let json = serde_json::to_string_pretty(&blocked).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + // === Communities === + + #[tool(description = "List all Vector Communities held locally (owned or joined), each with its channels and channel ids. Use a channel id as the chat_id for get_messages.")] + async fn list_communities(&self) -> Result { + let communities = self.core.list_communities().await; + let json = serde_json::to_string_pretty(&communities).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + #[tool(description = "Create a new Vector Community (single 'general' channel) owned by this identity. Signs the owner attestation so the creator is the proven owner. Returns the community + channel ids.")] + async fn create_community(&self, Parameters(req): Parameters) -> Result { + match self.core.create_community(&req.name).await { + Ok(summary) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".into()) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Mint a shareable public invite link for a Community this identity owns. Returns the URL.")] + async fn create_public_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.create_public_invite(&req.community_id).await { + Ok(url) => Ok(CallToolResult::success(vec![Content::text(url)])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Send a PRIVATE community invite: gift-wrap the invite bundle directly to an npub over a NIP-17 DM (NOT a shareable link). The recipient parks it pending consent (accept_pending_invite). Requires the create-invite permission; a banned npub can't be re-invited.")] + async fn send_private_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.invite_to_community(&req.community_id, &req.npub).await { + Ok(v) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "List the public invite links this account holds for a Community (each with its hex token, url, and expiry). Use a token with revoke_public_invite.")] + async fn list_public_invites(&self, Parameters(req): Parameters) -> Result { + match self.core.list_public_invites(&req.community_id) { + Ok(records) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&records).unwrap_or_else(|_| "[]".into()) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Revoke a public invite link by its hex token. Retiring the LAST active link flips the Community to Private, which re-keys (re-founds) to cut link-joined lurkers. Needs a local key when it triggers that rekey.")] + async fn revoke_public_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.revoke_public_invite(&req.community_id, &req.token).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Revoked.")])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Join a Vector Community from a public invite URL. Fetches the invite bundle, joins, and registers its channels as chats.")] + async fn join_community(&self, Parameters(req): Parameters) -> Result { + match self.core.join_community(&req.invite_url).await { + Ok(summary) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".into()) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "List PRIVATE community invites received via gift-wrapped DM and parked awaiting consent (each: community_id, name, inviter_npub). Accept one with accept_pending_invite.")] + async fn list_pending_invites(&self) -> Result { + match self.core.list_pending_invites() { + Ok(list) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".into()) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Accept a parked PRIVATE community invite by community_id (consent-then-join for a gift-wrapped invite). Folds the latest control plane, registers channels, announces presence. See list_pending_invites.")] + async fn accept_pending_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.accept_pending_invite(&req.community_id).await { + Ok(summary) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".into()) + )])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Send a text message to a Vector Community channel. Returns the message id.")] + async fn send_community_message(&self, Parameters(req): Parameters) -> Result { + match self.core.send_community_message(&req.channel_id, &req.content, req.replied_to.as_deref()).await { + Ok(id) => Ok(CallToolResult::success(vec![Content::text(format!("Sent (message id {id})"))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Fetch the latest page of a Community channel from relays (messages, reactions, edits, deletes, presence). Returns the count of new messages. Then use get_messages to read them.")] + async fn sync_community_channel(&self, Parameters(req): Parameters) -> Result { + match self.core.sync_community_channel(&req.channel_id, req.limit).await { + Ok((n, warnings)) => { + // Surface non-fatal warnings (catch-up / control-fold / read-cut-resume errors) so the agent + // is never blind to "the sync ran but a re-founding couldn't be resumed." + let mut msg = format!("Synced: {n} new message(s)"); + if !warnings.is_empty() { + msg.push_str("\n⚠ warnings:"); + for w in &warnings { + msg.push_str(&format!("\n - {w}")); + } + } + Ok(CallToolResult::success(vec![Content::text(msg)])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "List the observed members of a Community (people who've posted or announced a join, minus those who left or are banned). Each is {npub, last_active}.")] + async fn get_community_members(&self, Parameters(req): Parameters) -> Result { + let members = self.core.get_community_members(&req.community_id).await; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&members).unwrap_or_else(|_| "[]".into()) + )])) + } + + #[tool(description = "Leave a Community: announces a 'left' presence, then drops the held keys and local channels. A fresh invite is needed to rejoin.")] + async fn leave_community(&self, Parameters(req): Parameters) -> Result { + match self.core.leave_community(&req.community_id).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Left the community.")])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "My management capabilities in a Community (manage_metadata, kick, ban, manage_roles, etc.). Use to confirm a promotion/demotion landed. Sync the channel first so the roster is current.")] + async fn get_community_capabilities(&self, Parameters(req): Parameters) -> Result { + match self.core.community_capabilities(&req.community_id) { + Ok(v) => Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "The Community's roles: the owner npub and the list of admin npubs. Sync the channel first so the roster is current.")] + async fn get_community_roles(&self, Parameters(req): Parameters) -> Result { + match self.core.community_roles(&req.community_id) { + Ok(v) => Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Grant a member the Community @admin role. Requires manage_roles + outranking the role. Re-verified by every peer.")] + async fn grant_community_admin(&self, Parameters(req): Parameters) -> Result { + match self.core.grant_admin(&req.community_id, &req.npub).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Granted @admin to {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Revoke a member's Community @admin role.")] + async fn revoke_community_admin(&self, Parameters(req): Parameters) -> Result { + match self.core.revoke_admin(&req.community_id, &req.npub).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Revoked @admin from {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Kick a member (cooperative): they self-remove but can rejoin with a fresh invite. Requires the kick permission + outranking the target.")] + async fn kick_community_member(&self, Parameters(req): Parameters) -> Result { + match self.core.kick_member(&req.community_id, &req.npub).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Kicked {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Ban a member: terminal removal (no rejoin). In a PRIVATE community this also re-keys to cut their read access (needs a local key). Requires the ban permission + outranking the target.")] + async fn ban_community_member(&self, Parameters(req): Parameters) -> Result { + match self.core.set_member_banned(&req.community_id, &req.npub, true).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Banned {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Unban a member (removes them from the banlist so they may rejoin).")] + async fn unban_community_member(&self, Parameters(req): Parameters) -> Result { + match self.core.set_member_banned(&req.community_id, &req.npub, false).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Unbanned {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "DESTRUCTIVE, OWNER-ONLY, IRREVERSIBLE: dissolve (permanently delete) a Community. Publishes a terminal tombstone so no new messages or changes are EVER accepted by anyone (including you), and retires your own invite links. Cannot be undone. Already-sent messages aren't erased, but people can still delete their OWN past messages. Requires you to be the proven owner.")] + async fn delete_community(&self, Parameters(req): Parameters) -> Result { + match self.core.dissolve_community(&req.community_id).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Community dissolved (deleted). It is permanently sealed: no new activity will be accepted.")])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Edit a Community's name and/or description (requires the manage-metadata permission). Omit a field to leave it unchanged; an empty description clears it.")] + async fn edit_community_metadata(&self, Parameters(req): Parameters) -> Result { + match self.core.edit_community_metadata(&req.community_id, req.name.as_deref(), req.description.as_deref()).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Community metadata updated.")])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } +} + +#[tool_handler] +impl ServerHandler for VectorAgent { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .build(), + ) + .with_server_info(Implementation::new("vector-agent", env!("CARGO_PKG_VERSION"))) + } +} diff --git a/crates/vector-cli/Cargo.toml b/crates/vector-cli/Cargo.toml new file mode 100644 index 00000000..a5d5c00c --- /dev/null +++ b/crates/vector-cli/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "vector-cli" +version = "0.1.0" +edition = "2021" +description = "CLI client for Vector built on vector-core." + +[dependencies] +vector-core = { path = "../vector-core" } +tokio = { version = "1.49.0", features = ["full"] } +serde_json = "1.0" diff --git a/crates/vector-cli/src/main.rs b/crates/vector-cli/src/main.rs new file mode 100644 index 00000000..9c844663 --- /dev/null +++ b/crates/vector-cli/src/main.rs @@ -0,0 +1,236 @@ +use std::path::PathBuf; +use std::sync::Arc; +use vector_core::{VectorCore, CoreConfig, SendCallback, SendConfig}; +use vector_core::types::Message; + +// ============================================================================ +// CLI Send Callback — real-time terminal feedback +// ============================================================================ + +struct CliSendCallback; + +impl SendCallback for CliSendCallback { + fn on_upload_progress( + &self, + _pending_id: &str, + percentage: u8, + bytes_sent: u64, + ) -> Result<(), String> { + print!("\r [upload] {}% ({} bytes)", percentage, bytes_sent); + if percentage >= 100 { + println!(); + } + Ok(()) + } + + fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _att_id: &str, url: &str) { + println!(" [uploaded] {}", url); + } + + fn on_sent(&self, _chat_id: &str, _old_id: &str, msg: &Message) { + println!(" [sent] {}", &msg.id); + } + + fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) { + eprintln!(" [FAILED]"); + } +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + let data_dir = dirs_or_default(); + std::fs::create_dir_all(&data_dir).ok(); + + let core = VectorCore::init(CoreConfig { + data_dir, + event_emitter: None, + }).expect("Failed to initialize Vector Core"); + + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + print_usage(); + return; + } + + match args[1].as_str() { + "--login" | "login" => { + if args.len() < 3 { + eprintln!("Usage: vector-cli login "); + std::process::exit(1); + } + match core.login(&args[2], None).await { + Ok(result) => { + println!("Logged in as {}", result.npub); + println!("Encryption: {}", if result.has_encryption { "enabled" } else { "none" }); + println!("Connecting to relays..."); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + println!("Ready."); + } + Err(e) => { + eprintln!("Login failed: {}", e); + std::process::exit(1); + } + } + } + + "--send" | "send" => { + if args.len() < 4 { + eprintln!("Usage: vector-cli send "); + std::process::exit(1); + } + let npub = &args[2]; + let content = args[3..].join(" "); + + auto_login(&core).await; + + println!("Sending DM to {}...", &npub[..20.min(npub.len())]); + let config = SendConfig { self_send: true, ..Default::default() }; + match vector_core::sending::send_dm(npub, &content, None, &config, Arc::new(CliSendCallback)).await { + Ok(_) => {} + Err(e) => { + eprintln!("Send failed: {}", e); + std::process::exit(1); + } + } + } + + "--send-file" | "send-file" => { + if args.len() < 4 { + eprintln!("Usage: vector-cli send-file "); + std::process::exit(1); + } + let npub = &args[2]; + let filepath = &args[3]; + + auto_login(&core).await; + + let path = std::path::Path::new(filepath); + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + eprintln!("Failed to read file: {}", e); + std::process::exit(1); + } + }; + let filename = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file"); + let extension = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin"); + + println!("Sending {} to {}...", filename, &npub[..20.min(npub.len())]); + let config = SendConfig { self_send: true, ..Default::default() }; + match vector_core::sending::send_file_dm( + npub, Arc::new(bytes), filename, extension, None, + &config, Arc::new(CliSendCallback), + ).await { + Ok(_) => {} + Err(e) => { + eprintln!("File send failed: {}", e); + std::process::exit(1); + } + } + } + + "--accounts" | "accounts" => { + match core.accounts() { + Ok(accounts) if !accounts.is_empty() => { + println!("Accounts:"); + for (i, acc) in accounts.iter().enumerate() { + println!(" {}. {}", i + 1, acc); + } + } + Ok(_) => println!("No accounts. Use: vector-cli login "), + Err(e) => eprintln!("Error: {}", e), + } + } + + "--whoami" | "whoami" => { + auto_login(&core).await; + match core.my_npub() { + Some(npub) => println!("{}", npub), + None => eprintln!("Not logged in"), + } + } + + _ => { + eprintln!("Unknown command: {}", args[1]); + print_usage(); + std::process::exit(1); + } + } + + core.logout().await; +} + +async fn auto_login(core: &VectorCore) { + let accounts = core.accounts().unwrap_or_default(); + if accounts.is_empty() { + eprintln!("No account found. Run: vector-cli login "); + std::process::exit(1); + } + + let npub = &accounts[0]; + vector_core::db::set_current_account(npub.clone()).ok(); + vector_core::db::init_database(npub).ok(); + + let pkey = match vector_core::db::get_pkey() { + Ok(Some(k)) => k, + _ => { + eprintln!("No stored key for {}. Run: vector-cli login ", npub); + std::process::exit(1); + } + }; + + match core.login(&pkey, None).await { + Ok(_) => { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + Err(e) => { + eprintln!("Auto-login failed: {}", e); + std::process::exit(1); + } + } +} + +fn print_usage() { + println!("Vector CLI v0.1.0"); + println!(); + println!("Usage:"); + println!(" vector-cli login Login / create account"); + println!(" vector-cli send Send a text DM"); + println!(" vector-cli send-file Send a file DM"); + println!(" vector-cli accounts List stored accounts"); + println!(" vector-cli whoami Show current npub"); +} + +fn dirs_or_default() -> PathBuf { + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join("Library/Application Support/io.vectorapp/data"); + } + } + #[cfg(target_os = "linux")] + { + if let Ok(data) = std::env::var("XDG_DATA_HOME") { + return PathBuf::from(data).join("io.vectorapp/data"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".local/share/io.vectorapp/data"); + } + } + #[cfg(target_os = "windows")] + { + if let Ok(appdata) = std::env::var("APPDATA") { + return PathBuf::from(appdata).join("io.vectorapp/data"); + } + } + PathBuf::from("/tmp/vector-data") +} diff --git a/crates/vector-core/Cargo.toml b/crates/vector-core/Cargo.toml new file mode 100644 index 00000000..80742c02 --- /dev/null +++ b/crates/vector-core/Cargo.toml @@ -0,0 +1,93 @@ +[package] +name = "vector-core" +version = "0.1.0" +edition = "2021" +description = "Core library for Vector — the single source of truth for all Vector clients, SDKs, and interfaces." +license = "MIT" +repository = "https://github.com/VectorPrivacy/Vector" +homepage = "https://vectorapp.io" +documentation = "https://docs.rs/vector-core" +readme = "README.md" +keywords = ["nostr", "messaging", "encryption", "vector"] +categories = ["network-programming", "cryptography", "asynchronous"] + +[dependencies] +# Nostr Protocol. Declared as crates.io versions so the crate is publishable; the +# workspace `[patch.crates-io]` redirects the shared `nostr` to the zeroize fork for +# in-workspace builds (so the app keeps secret-key zeroization), while published +# crates resolve to stock nostr. +nostr = "0.44" +nostr-sdk = { version = "0.44.1", features = ["nip06", "nip44", "nip59"] } +nostr-blossom = "0.44.0" +nostr-connect = "0.44" +bip39 = { version = "2.2.2", features = ["rand"] } + +# Database +rusqlite = { version = "0.37", features = ["bundled"] } + +# Async +tokio = { version = "1.49.0", features = ["sync", "time", "net", "io-util", "rt-multi-thread", "macros"] } +futures-util = "0.3.31" +# Boxed, Send futures for the Community Transport trait so impls work in spawned tasks. +async-trait = "0.1" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Crypto +aes = "0.8.4" +aes-gcm = "0.10.3" +chacha20poly1305 = "0.10.1" +argon2 = "0.5.3" +sha2 = "0.10.9" +# HKDF-SHA256 for the Community protocol's frozen key-derivation convention +# (GROUP_PROTOCOL.md §14). Audited RustCrypto crate rather than a hand-rolled +# construction, since the derivation is wire-immutable. +hkdf = "0.12" +zeroize = { version = "1.8", features = ["derive"] } + +# HTTP +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "charset", "socks"] } +url = "2.5.7" + +# Image metadata +fast-thumbhash = "0.2" + +# Utils +rand = "0.8" +base64-simd = "0.8" +image = { version = "0.25.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] } + +# TLS +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } + +# Tor (Arti) — stock crates.io. Now that Vector is on rusqlite 0.37+ we share +# libsqlite3-sys with Arti's tor-dirmgr directly, so the rusqlite-0.32 fork is gone. +arti-client = { version = "0.41", default-features = false, features = ["tokio", "rustls", "experimental-api", "bridge-client", "pt-client"], optional = true } +tor-rtcompat = { version = "0.41", default-features = false, features = ["tokio", "rustls"], optional = true } +# For circuit introspection (the "Advanced" panel showing live hops). +tor-circmgr = { version = "0.41", default-features = false, optional = true } +tor-dirmgr = { version = "0.41", default-features = false, optional = true } +tor-linkspec = { version = "0.41", default-features = false, optional = true } +tor-guardmgr = { version = "0.41", default-features = false, features = ["bridge-client", "pt-client"], optional = true } +# tokio_util::compat bridges arti's futures::io::AsyncRead to tokio::io::AsyncRead so +# we can splice Arti's DataStream into a tokio TcpStream for the SOCKS5 bridge. +tokio-util = { version = "0.7", features = ["compat"], optional = true } + +# Platform +libc = "0.2" + +[features] +default = [] +# Enables embedded Tor (Arti) — bootstraps Arti, runs a localhost SOCKS5 listener +# bridging into the Tor network. Consumers (HTTP / Nostr) opt into the proxy via +# `tor::proxy_url()` returning `Some(socks5://127.0.0.1:)` when active. +tor = ["dep:arti-client", "dep:tor-rtcompat", "dep:tokio-util", "dep:tor-circmgr", "dep:tor-dirmgr", "dep:tor-linkspec", "dep:tor-guardmgr"] + +[dev-dependencies] +tempfile = "3" +# `test-util` (test-only — NOT in the production tokio features) enables tokio's virtual clock +# (`start_paused`) so timing-based tests (e.g. the inbox-relays debounce window) resolve +# deterministically instead of depending on wall-clock margins under parallel CPU load. +tokio = { version = "1.49.0", features = ["test-util"] } diff --git a/crates/vector-core/README.md b/crates/vector-core/README.md new file mode 100644 index 00000000..bd3941f3 --- /dev/null +++ b/crates/vector-core/README.md @@ -0,0 +1,82 @@ +# Vector Core + +The headless engine behind [Vector](https://vectorapp.io) — a private messenger on +the [Nostr](https://nostr.com) protocol. `vector-core` is the **single source of +truth** for every Vector client: the desktop and mobile apps, the CLIs, the +[bot SDK](https://docs.rs/vector-sdk), and the MCP agent all import this one crate. +It holds all of the protocol logic so a client never has to touch a relay, a +gift-wrap, or an encryption key directly. + +> **Building a bot?** You almost certainly want [`vector-sdk`](https://crates.io/crates/vector-sdk), +> an ergonomic, discord.js-style layer over this crate. Use `vector-core` directly +> when you're building a full custom client and want the low-level facade. + +## What's inside + +- **NIP-17 gift-wrapped DMs** — send/reply/edit/delete, reactions (incl. NIP-30 custom + emoji), typing indicators, read state. +- **Communities (Concord)** — a Discord-style end-to-end-encrypted server/channel + protocol over Nostr: epoch-keyed channels, roles & capabilities, invites + (public links + gift-wrapped), bans/kicks with read-cut rekeys, re-foundings. +- **Encrypted files** — NIP-96 / Blossom upload with progress, retry, and server + failover; AES-GCM at rest; hash-based dedup. Plus unencrypted public-image upload + for avatars/banners. +- **Profiles** — fetch/update/status, blocking, local nicknames, a prioritized + background sync queue. +- **Storage & crypto** — per-account SQLite (atomic migrations, connection pools), + a `GuardedKey` vault (secret material in plaintext only microseconds per op), + Argon2id, AES-GCM, ChaCha20, and SIMD hex (NEON / SSE2 / AVX2 with scalar fallback). +- **Resilience** — NIP-77 negentropy sync and an event-driven relay health monitor + that reconnects dead relays and folds back anything missed offline. +- **Headless by design** — UI integration is abstracted behind the `EventEmitter` + and `InboundEventHandler` traits, so the same engine drives a GUI, a CLI, or a bot. + +## Quick start + +```toml +[dependencies] +vector-core = "0.1" +tokio = { version = "1", features = ["full"] } +``` + +```rust +use vector_core::{CoreConfig, VectorCore}; + +#[tokio::main] +async fn main() -> vector_core::Result<()> { + let core = VectorCore::init(CoreConfig { + data_dir: "./vector-data".into(), + event_emitter: None, + })?; + + core.login("nsec1...", None).await?; // or a BIP-39 mnemonic + core.send_dm("npub1...", "Hello from vector-core").await?; + + Ok(()) +} +``` + +The `VectorCore` facade is a zero-sized handle over process-global state — cheap to +`Copy`, and every method (DMs, communities, profiles, files, sync) hangs off it. +To receive, implement [`InboundEventHandler`] and call `core.listen(handler).await`. + +## One identity per process + +`vector-core` is built on process-global state, so **one account is active per +process at a time**. Multiple identities means multiple processes — or +`core.swap_session()` to switch the active account in place. (Swaps can happen at +any await point; account-scoped work must guard against them — see `SessionGuard`.) + +## The `nostr` dependency + +`vector-core` depends on stock [`nostr`](https://crates.io/crates/nostr) from +crates.io. Within the Vector monorepo, a workspace `[patch.crates-io]` swaps in a +small fork that zeroizes secret keys on drop (defense-in-depth for the GUI app); +that patch is local to the workspace and does **not** affect this published crate or +its consumers, who get stock `nostr` and need no patch. + +## License + +MIT. + +[`InboundEventHandler`]: https://docs.rs/vector-core/latest/vector_core/trait.InboundEventHandler.html diff --git a/crates/vector-core/examples/concord_control_dump.rs b/crates/vector-core/examples/concord_control_dump.rs new file mode 100644 index 00000000..627e9c31 --- /dev/null +++ b/crates/vector-core/examples/concord_control_dump.rs @@ -0,0 +1,125 @@ +//! One-off diagnostic: fetch a Community's control plane live and print exactly what the fold sees — +//! the GroupRoot candidates (name + author + inner_id), what's gapped/quarantined, the folded grants, +//! and whether a given author resolves as MANAGE_METADATA-authorized. There are no Concord logs, so this +//! is the only window besides the raw DB. +//! +//! Usage: cargo run -p vector-core --example concord_control_dump -- [author_hex] +//! Run against a COPY of the live DB (sqlite3 live.db ".backup copy.db") so it never locks/mutates the real one. + +use nostr_sdk::prelude::*; +use std::time::Duration; +use vector_core::community::{roster, CommunityId}; + +fn hex32(s: &str) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); + } + out +} +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{:02x}", x)).collect() +} + +#[tokio::main] +async fn main() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let args: Vec = std::env::args().collect(); + let app_dir = &args[1]; + let npub = &args[2]; + let cid_hex = &args[3]; + let author = args.get(4).cloned(); + + vector_core::db::set_app_data_dir(std::path::PathBuf::from(app_dir)); + vector_core::db::set_current_account(npub.clone()).unwrap(); + vector_core::db::init_database(npub).unwrap(); + + let cid = CommunityId(hex32(cid_hex)); + let community = vector_core::db::community::load_community(&cid).unwrap().expect("community not in DB"); + let owner_hex = community.owner_attestation.as_ref().and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex()); + println!("community = {:?} epoch = {} relays = {:?}", community.name, community.server_root_epoch.0, community.relays); + println!("proven owner = {:?}", owner_hex); + + // Fetch the control plane at the current server-root epoch pseudonym, exactly as fetch_control_folded does. + let pseudonym = roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch); + println!("control pseudonym = {}", pseudonym); + let client = Client::default(); + for r in &community.relays { + let _ = client.add_relay(r).await; + } + client.connect().await; + let filter = Filter::new() + .kind(Kind::Custom(3308)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::Z), [pseudonym]); + let events = client.fetch_events_from(community.relays.clone(), filter, Duration::from_secs(20)).await.unwrap(); + println!("\nfetched {} raw control events from relays", events.len()); + let inners: Vec = events.iter().filter_map(|e| roster::open_control_edition(e, &community.server_root_key).ok()).collect(); + println!("opened {} inner editions", inners.len()); + + let floors = vector_core::db::community::get_all_edition_heads(cid_hex).unwrap(); + if let Some((v, h)) = floors.get(cid_hex) { + println!("\nGroupRoot persisted floor: v{} self_hash={}", v, hx(h)); + } else { + println!("\nGroupRoot persisted floor: NONE (bootstrapping)"); + } + + let folded = roster::fold_roster(&inners, &community.id, &floors); + + println!("\n=== GroupRoot candidates (what the consumer scans, version desc / inner_id asc) ==="); + if folded.root_candidates.is_empty() { + println!(" (EMPTY — GroupRoot was quarantined or no edition >= floor)"); + } + for c in &folded.root_candidates { + let auth = owner_hex.as_deref().map(|o| { + roster::authorize_delegation(&folded, Some(o)).is_authorized(&c.author.to_hex(), Some(o), vector_core::community::roles::Permissions::MANAGE_METADATA) + }); + println!( + " v{} name={:?} author={} inner_id={} self_hash={} AUTHORIZED={:?}", + c.head.version, c.meta.name, c.author.to_hex(), hx(&c.head.inner_id), hx(&c.head.self_hash), auth + ); + } + + println!("\n=== gapped / quarantined entities ==="); + for g in &folded.gapped_entities { + let tag = if *g == community.id.0 { " <- GroupRoot" } else { "" }; + println!(" {}{}", hx(g), tag); + } + + println!("\n=== folded grants (authority) ==="); + for (i, g) in folded.roles.grants.iter().enumerate() { + let who = folded.grant_authors.get(i).map(|a| a.to_hex()).unwrap_or_default(); + println!(" member={} roles={:?} (granted by {})", g.member, g.role_ids, who); + } + + if let (Some(a), Some(o)) = (author.as_ref(), owner_hex.as_deref()) { + let authd = roster::authorize_delegation(&folded, Some(o)); + let yes = authd.is_authorized(a, Some(o), vector_core::community::roles::Permissions::MANAGE_METADATA); + println!("\nauthor {} MANAGE_METADATA authorized in THIS fold = {}", a, yes); + } + + // === RE-ANCHOR COVERAGE (why privatize aborts) === + // The base rotation demands every version 1..=head of every tracked entity be re-fetchable. Compute + // expected (from the persisted heads) vs what the live fetch actually returns, and print the gap. + use std::collections::HashSet; + let mut expected: HashSet<(String, u64)> = HashSet::new(); + for (entity, (head_v, _)) in &floors { + for v in 1..=*head_v { + expected.insert((entity.clone(), v)); + } + } + let mut fetched: HashSet<(String, u64)> = HashSet::new(); + for inner in &inners { + if let Ok(p) = vector_core::community::edition::parse_edition_inner(inner) { + fetched.insert((hx(&p.entity_id), p.version)); + } + } + let mut missing: Vec<(String, u64)> = expected.difference(&fetched).cloned().collect(); + missing.sort(); + println!("\n=== RE-ANCHOR COVERAGE ==="); + println!("expected (entity,version) pairs = {}", expected.len()); + println!("fetched distinct (entity,version) = {}", fetched.len()); + println!("MISSING from the live fetch = {} (each one aborts the base rotation):", missing.len()); + for (e, v) in &missing { + println!(" {} v{}", e, v); + } +} diff --git a/crates/vector-core/examples/invite_probe.rs b/crates/vector-core/examples/invite_probe.rs new file mode 100644 index 00000000..50a9e846 --- /dev/null +++ b/crates/vector-core/examples/invite_probe.rs @@ -0,0 +1,67 @@ +//! Probe a public-invite token's bundle on the relays: is the bundle still present, and is there a +//! NIP-09 deletion referencing its coordinate? Used to verify that revoking a public invite actually +//! removes it from relays. +//! +//! Usage: cargo run -p vector-core --example invite_probe -- + +use nostr_sdk::prelude::*; +use std::time::Duration; +use vector_core::community::public_invite; + +fn hex32(s: &str) -> [u8; 32] { + let mut o = [0u8; 32]; + for i in 0..32 { + o[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); + } + o +} + +#[tokio::main] +async fn main() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let args: Vec = std::env::args().collect(); + let token = hex32(&args[1]); + let relays: Vec = args[2].split(',').map(|s| s.to_string()).collect(); + + let signer_pk = public_invite::signer_pubkey(&token); + let locator = public_invite::locator_hex(&token); + let coord_a = format!("30078:{}:{}", signer_pk.to_hex(), locator); + println!("token = {}", args[1]); + println!("signer = {}", signer_pk.to_hex()); + println!("locator = {}", locator); + println!("coord = {}", coord_a); + + let client = Client::default(); + for r in &relays { + let _ = client.add_relay(r).await; + } + client.connect().await; + + // 1) The bundle itself, at (kind 30078, author=signer, #d=locator). + let bundle_filter = Filter::new().kind(Kind::Custom(30078)).author(signer_pk).identifier(locator.clone()); + let bundles = client.fetch_events_from(relays.clone(), bundle_filter, Duration::from_secs(15)).await.unwrap(); + println!("\nEvents at the coordinate: {}", bundles.len()); + for e in bundles.iter() { + let vsk = e.tags.iter().find_map(|t| { let s = t.as_slice(); (s.len() >= 2 && s[0] == "vsk").then(|| s[1].clone()) }).unwrap_or_default(); + let kind = if e.content.is_empty() && vsk == "9" { "TOMBSTONE (revoked)" } else if vsk == "6" { "LIVE BUNDLE" } else { "?" }; + println!(" vsk={} content_len={} -> {} (id={})", vsk, e.content.len(), kind, e.id); + } + + // 2) Any NIP-09 deletion (kind 5) by the token-signer referencing this coordinate (`a` tag). + let del_filter = Filter::new().kind(Kind::Custom(5)).author(signer_pk); + let dels = client.fetch_events_from(relays.clone(), del_filter, Duration::from_secs(15)).await.unwrap(); + let matching: Vec<_> = dels + .iter() + .filter(|e| e.tags.iter().any(|t| { let s = t.as_slice(); s.len() >= 2 && s[0] == "a" && s[1] == coord_a })) + .collect(); + println!("\nDELETION (kind 5) events by the signer referencing this coord: {}", matching.len()); + for e in &matching { + println!(" id={} created_at={}", e.id, e.created_at.as_u64()); + } + + println!( + "\n=> bundle is {} on relays; matching deletion is {} present", + if bundles.is_empty() { "GONE" } else { "STILL PRESENT" }, + if matching.is_empty() { "NOT" } else { "" } + ); +} diff --git a/crates/vector-core/src/badges.rs b/crates/vector-core/src/badges.rs new file mode 100644 index 00000000..7e05a0f9 --- /dev/null +++ b/crates/vector-core/src/badges.rs @@ -0,0 +1,193 @@ +//! Profile badges — fetch, validate, and cache. +//! +//! Badges are not loaded during the critical boot path. The cache is filled +//! once after initial sync (see `refresh_own_badges`) so badge-gated perks +//! (e.g. raised emoji-pack limits) resolve without an on-demand network +//! round-trip. `has_vector_badge` is the cheap synchronous reader used by +//! those gates. + +use nostr_sdk::prelude::*; + +// Guy Fawkes Day 2025 — V for Vector badge claim window. +const FAWKES_DAY_START: u64 = 1762300800; // 2025-11-05 00:00:00 UTC +const FAWKES_DAY_END: u64 = 1762387200; // 2025-11-06 00:00:00 UTC + +/// Per-account settings key: "true" once we've confirmed the Vector badge. +const BADGE_VECTOR_KEY: &str = "badge_vector"; +/// Per-account settings key: unix-secs of the last unsuccessful resolve pass. +/// Throttles re-checking for accounts that don't (yet) hold the badge. +const BADGE_CHECK_TS_KEY: &str = "badge_check_ts"; +/// Don't re-run the full retry loop more than this often for an account we've +/// already checked without success. The claim window is permanently closed, so +/// a non-holder can never become a holder — frequent restarts shouldn't each +/// trigger a fresh relay sweep. +const RECHECK_COOLDOWN_SECS: u64 = 6 * 3600; + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Whether a kind-30078 event is a valid Fawkes badge claim: right content and +/// a timestamp inside the (half-open) event window. Pure so it's unit-testable. +fn is_valid_fawkes_claim(content: &str, created_at: u64) -> bool { + content == "fawkes_badge_claimed" + && created_at >= FAWKES_DAY_START + && created_at < FAWKES_DAY_END +} + +/// Fetch + validate whether `pubkey` holds the V for Vector (Guy Fawkes 2025) +/// badge: a kind-30078 `d=fawkes_2025` claim published within the event window. +/// +/// Queries the full relay pool rather than only the trusted relays: the claim +/// was published during the event to whatever relays the holder used, and any +/// single relay (including a trusted one) can be transiently down. Broadest +/// net gives the best chance of locating the permanent claim. +pub async fn has_fawkes_badge(pubkey: &PublicKey) -> Result { + let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?; + let filter = Filter::new() + .author(*pubkey) + .kind(Kind::ApplicationSpecificData) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), "fawkes_2025") + // > 1 to tolerate relays serving superseded copies of the replaceable + // event alongside the current one. + .limit(10); + let mut events = client + .stream_events(filter, std::time::Duration::from_secs(10)) + .await + .map_err(|e| e.to_string())?; + while let Some(event) = events.next().await { + if is_valid_fawkes_claim(&event.content, event.created_at.as_secs()) { + return Ok(true); + } + } + Ok(false) +} + +/// Cached flag for whether we hold the Vector badge. Cheap + synchronous, so +/// safe to call from limit checks. Defaults to false when unset — badge perks +/// stay off until the cache is filled post-sync. +pub fn has_vector_badge() -> bool { + crate::db::get_sql_setting(BADGE_VECTOR_KEY.to_string()) + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false) +} + +/// Record the result of an on-demand badge check. When the checked key is our +/// own and the badge is present, persist it (sticky) and emit `badges_updated` +/// so badge-gated perks (raised emoji-pack limits) turn on immediately — this is +/// the safety net for a post-sync `refresh_own_badges` that missed the claim +/// (the holding relay is often flaky during the saturated sync window) and is now +/// sitting in its multi-hour re-check cooldown. An on-demand check runs at a +/// quiet moment, so it lands where the sync-time sweep didn't. +/// +/// No-op for other users, a negative result, or an account swap mid-check (the +/// own-key comparison re-reads the *current* account, so a stale key never +/// writes the wrong DB). No awaits, so the read + write stay on one account. +pub fn note_own_badge_confirmed(pubkey: &PublicKey, has_badge: bool) { + if !has_badge || has_vector_badge() { + return; + } + if crate::state::my_public_key().as_ref() != Some(pubkey) { + return; + } + let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string()); + crate::log_info!("[Badges] vector badge confirmed via on-demand check"); + crate::traits::emit_event_json("badges_updated", serde_json::json!({ "vector": true })); +} + +/// Fetch our own badges and persist to the per-account cache. Called once +/// after initial sync. The SessionGuard straddles the network fetch so a +/// mid-fetch account swap can't write account A's badge into account B's DB. +pub async fn refresh_own_badges() { + let session = crate::state::SessionGuard::capture(); + let Some(pk) = crate::state::my_public_key() else { + crate::log_warn!("[Badges] refresh skipped — no public key"); + return; + }; + + // Sticky: the badge is a permanent achievement, so once confirmed we never + // re-query (avoids a flaky relay later flipping it off) and never downgrade. + if has_vector_badge() { + crate::log_info!("[Badges] vector badge already cached — skipping refresh"); + return; + } + + // Throttle: skip the relay sweep if we already checked recently without + // success. The window is closed, so a miss now will still be a miss in an + // hour — no need to re-sweep on every restart. + let now = unix_now(); + if let Some(last) = crate::db::get_sql_setting(BADGE_CHECK_TS_KEY.to_string()) + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + { + if now.saturating_sub(last) < RECHECK_COOLDOWN_SECS { + return; + } + } + + crate::log_info!( + "[Badges] resolving own badges for {}…", + pk.to_bech32().unwrap_or_default() + ); + + // The holding relay (often the user's own) is flaky/overloaded during the + // heavy sync window, so retry a few times to catch it during a quiet + // moment. A miss leaves the badge cache untouched (records only the check + // time for the cooldown); the next boot past the cooldown tries again until + // it lands once (then sticky-cached forever). + const ATTEMPTS: u8 = 3; + for attempt in 1..=ATTEMPTS { + match has_fawkes_badge(&pk).await { + Ok(true) => { + if !session.is_valid() { + return; + } + crate::log_info!("[Badges] vector badge confirmed (attempt {})", attempt); + let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string()); + return; + } + Ok(false) => { + crate::log_info!("[Badges] vector badge not found (attempt {}/{})", attempt, ATTEMPTS); + } + Err(e) => { + crate::log_warn!("[Badges] refresh attempt {}/{} failed: {}", attempt, ATTEMPTS, e); + } + } + if !session.is_valid() { + return; + } + if attempt < ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(20)).await; + } + } + // Record the unsuccessful pass so the cooldown applies before re-sweeping. + if session.is_valid() { + let _ = crate::db::set_sql_setting(BADGE_CHECK_TS_KEY.to_string(), now.to_string()); + } + crate::log_info!("[Badges] vector badge not resolved this boot — will retry after cooldown"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fawkes_claim_window_boundaries() { + // Correct content, inside the window. + assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START)); + assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END - 1)); + // End is exclusive. + assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END)); + // Before the window. + assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START - 1)); + // Wrong / empty content, even inside the window. + assert!(!is_valid_fawkes_claim("", FAWKES_DAY_START)); + assert!(!is_valid_fawkes_claim("something_else", FAWKES_DAY_START)); + } +} diff --git a/crates/vector-core/src/blossom.rs b/crates/vector-core/src/blossom.rs new file mode 100644 index 00000000..88a84313 --- /dev/null +++ b/crates/vector-core/src/blossom.rs @@ -0,0 +1,964 @@ +use nostr_sdk::{NostrSigner, Url, Event, EventBuilder, Timestamp, JsonUtil}; +use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash}; +use nostr_blossom::prelude::*; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE}; +use reqwest::{Body, StatusCode}; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::sync::mpsc; +use futures_util::Stream; +use std::pin::Pin; +use std::task::{Context, Poll}; + +/// Progress callback function type +pub type ProgressCallback = std::sync::Arc, Option) -> Result<(), String> + Send + Sync>; + +/// Custom upload stream that tracks progress +struct ProgressTrackingStream { + bytes_sent: Arc>, + inner: mpsc::Receiver, std::io::Error>>, +} + +impl ProgressTrackingStream { + fn new(data: Arc>, bytes_sent: Arc>) -> Self { + let (tx, rx) = mpsc::channel(8); // Buffer size of 8 chunks + + // Spawn a background task to feed the stream + tokio::spawn(async move { + let chunk_size = 64 * 1024; // 64 KB chunks - only unavoidable copy + let mut position = 0; + + while position < data.len() { + let end = std::cmp::min(position + chunk_size, data.len()); + let chunk = data[position..end].to_vec(); + + // Send chunk through channel + if tx.send(Ok(chunk)).await.is_err() { + break; // Receiver was dropped + } + + position = end; + } + }); + + Self { + bytes_sent, + inner: rx, + } + } +} + +impl Stream for ProgressTrackingStream { + type Item = Result, std::io::Error>; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + match self.inner.poll_recv(cx) { + Poll::Ready(Some(result)) => { + // Update the bytes sent counter + if let Ok(chunk) = &result { + let mut bytes_sent = self.bytes_sent.lock().unwrap(); + *bytes_sent += chunk.len() as u64; + } + Poll::Ready(Some(result)) + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +/// Builds the Blossom authorization header +async fn build_auth_header( + signer: &T, + hash: Sha256Hash, +) -> Result +where + T: NostrSigner, +{ + // Create Blossom authorization + let expiration = Timestamp::now() + std::time::Duration::from_secs(300); + let auth = BlossomAuthorization::new( + "Blossom upload authorization".to_string(), + expiration, + BlossomAuthorizationVerb::Upload, + BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]), + ); + + // Sign the authorization event + let auth_event: Event = EventBuilder::blossom_auth(auth) + .sign(signer) + .await + .map_err(|e| format!("Failed to sign auth event: {}", e))?; + + // Encode as base64 + let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json()); + let value = format!("Nostr {}", encoded_auth); + + HeaderValue::try_from(value) + .map_err(|e| format!("Failed to create header value: {}", e)) +} + +/// Upload to a single Blossom server with progress callbacks. +/// `retry_count` defaults to 0; `retry_spacing` defaults to 1s. +pub async fn upload_blob_with_progress( + signer: T, + server_url: &Url, + file_data: Arc>, + mime_type: Option<&str>, + progress_callback: ProgressCallback, + retry_count: Option, + retry_spacing: Option, + cancel_flag: Option>, +) -> Result +where + T: NostrSigner + Clone, +{ + let retry_count = retry_count.unwrap_or(0); + let retry_spacing = retry_spacing.unwrap_or(std::time::Duration::from_secs(1)); + + let mut last_error = None; + + for attempt in 0..=retry_count { + if attempt > 0 { + tokio::time::sleep(retry_spacing).await; + } + + if let Some(ref flag) = cancel_flag { + if flag.load(Ordering::Relaxed) { + return Err("Upload cancelled".to_string()); + } + } + + match upload_attempt( + signer.clone(), + server_url, + file_data.clone(), + mime_type, + &progress_callback, + cancel_flag.clone(), + ).await { + Ok(url) => return Ok(url), + Err(e) => { + if e == "Upload cancelled" { + return Err(e); + } + crate::log_warn!( + "[Blossom] Attempt {}/{} to {} failed: {}", + attempt + 1, retry_count + 1, server_url, e, + ); + // Deterministic rejections (413/415 etc.) — outer failover handles them. + let status = parse_status_from_error(&e); + let permanent = crate::blossom_capabilities::is_mime_rejection(status, &e) + || crate::blossom_capabilities::is_size_rejection(status); + if permanent { + return Err(e); + } + // Gateway timeouts (Cloudflare 524/522/520, 504): the origin couldn't ingest the upload + // within the edge window (a too-slow/too-large upload). Retrying the same server just + // repeats the multi-minute timeout, so route around to the next server immediately. + if matches!(status, Some(504 | 520 | 522 | 524)) { + crate::log_warn!( + "[Blossom] {} gateway-timed-out (status {}) on {} bytes; routing to the next server", + server_url, status.unwrap_or(0), file_data.len(), + ); + return Err(e); + } + // On large uploads, mid-stream drops are almost always a + // size policy; don't burn retries. Below 8MB, treat as a + // genuine transient blip and retry. + let looks_like_mid_stream_drop = ( + e.contains("Upload request failed") + || e.contains("error sending request") + || e.contains("connection reset") + || e.contains("connection closed") + || e.contains("connection refused") + || e.contains("body write") + || e.contains("IncompleteMessage") + || e.contains("broken pipe") + ) && file_data.len() > 8 * 1024 * 1024; + if looks_like_mid_stream_drop { + crate::log_warn!( + "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent", + server_url, file_data.len(), + ); + return Err(e); + } + last_error = Some(e); + } + } + } + + // All attempts failed, return the last error + Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string())) +} + +/// Internal function that performs a single upload attempt with progress tracking +async fn upload_attempt( + signer: T, + server_url: &Url, + file_data: Arc>, + mime_type: Option<&str>, + progress_callback: &ProgressCallback, + cancel_flag: Option>, +) -> Result +where + T: NostrSigner, +{ + let upload_url = server_url.join("upload") + .map_err(|e| format!("Invalid server URL: {}", e))?; + + let total_size = file_data.len() as u64; + let hash = Sha256Hash::hash(&*file_data); + + progress_callback(Some(0), Some(0)).map_err(|e| e)?; + + // One auth event covers both HEAD preflight and PUT. + let auth_header = build_auth_header(&signer, hash).await?; + + // Redirects disabled: a 3xx mid-PUT would re-issue as GET and drop the body. + let client = crate::net::build_http_client_with_options( + std::time::Duration::from_secs(300), + false, + )?; + + // BUD-06 preflight (best-effort; non-supporting servers 404/405). + { + let mut head_headers = HeaderMap::new(); + head_headers.insert(AUTHORIZATION, auth_header.clone()); + head_headers.insert( + "X-Content-Length", + HeaderValue::from_str(&total_size.to_string()) + .map_err(|e| format!("Invalid X-Content-Length: {}", e))?, + ); + // BUD-06 requires lowercase hex. SIMD encode of the 32-byte digest (sha256::Hash displays + // in forward byte order, matching to_byte_array — see the parity test). + head_headers.insert( + "X-SHA-256", + HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array())) + .map_err(|e| format!("Invalid X-SHA-256: {}", e))?, + ); + if let Some(ct) = mime_type { + head_headers.insert( + "X-Content-Type", + HeaderValue::from_str(ct).map_err(|e| format!("Invalid X-Content-Type: {}", e))?, + ); + } + match tokio::time::timeout( + std::time::Duration::from_secs(5), + client.head(upload_url.clone()).headers(head_headers).send(), + ).await { + Ok(Ok(resp)) => { + let status = resp.status(); + // BUD-02: X-Reason is display-only. Body IS fed to the classifier + // to catch non-compliant servers that 400 instead of 415. + let x_reason = resp.headers().get("X-Reason") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let body = resp.text().await.unwrap_or_default(); + let diag = if !body.is_empty() { + if let Some(r) = &x_reason { + format!("{} (X-Reason: {})", body, r) + } else { + body + } + } else if let Some(r) = x_reason { + r + } else { + format!("rejected at preflight ({})", status) + }; + let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE; + let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE; + let mime_hinted = status.is_client_error() && !is_413 && { + crate::blossom_capabilities::is_mime_rejection(Some(status.as_u16()), &diag) + }; + if is_413 || is_415 || mime_hinted { + crate::log_warn!( + "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}", + server_url, status, total_size, + mime_type.unwrap_or("(no mime)"), diag, + ); + return Err(format!( + "Upload failed with status {}: {}", + status, diag, + )); + } + crate::log_debug!( + "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT", + server_url, status, total_size, + ); + } + Ok(Err(e)) => { + crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e); + } + Err(_) => { + crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url); + } + } + } + + let bytes_sent = Arc::new(Mutex::new(0u64)); + let tracking_stream = ProgressTrackingStream::new(file_data, Arc::clone(&bytes_sent)); + let body = Body::wrap_stream(tracking_stream); + + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth_header); + if let Some(ct) = mime_type { + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))? + ); + } + // `Body::wrap_stream` is unknown-length so reqwest would default to + // chunked encoding and omit Content-Length — some servers (e.g. + // blossom.data.haus) then 411. + headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size)); + + let mut request_future = Box::pin(client + .put(upload_url.clone()) + .headers(headers) + .body(body) + .send()); + + let mut last_percentage = 0; + let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(100)); + + let response = loop { + tokio::select! { + response = &mut request_future => { + break response.map_err(|e| format!("Upload request failed: {}", e))?; + }, + _ = poll_interval.tick() => { + if let Some(ref flag) = cancel_flag { + if flag.load(Ordering::Relaxed) { + return Err("Upload cancelled".to_string()); + } + } + + let current_bytes = *bytes_sent.lock().unwrap(); + let percentage = if total_size > 0 { + ((current_bytes as f64 / total_size as f64) * 100.0) as u8 + } else { + 0 + }; + + if percentage != last_percentage { + if let Err(e) = progress_callback(Some(percentage), Some(current_bytes)) { + return Err(e); + } + last_percentage = percentage; + } + } + } + }; + + let final_bytes = *bytes_sent.lock().unwrap(); + if final_bytes == total_size && last_percentage < 100 { + progress_callback(Some(100), Some(total_size)).map_err(|e| e)?; + } + + // BUD-02: accept any 2xx (200 OK or 201 Created). + let status = response.status(); + if status.is_success() { + let descriptor: BlobDescriptor = response.json().await + .map_err(|e| format!("Failed to parse response: {}", e))?; + // Integrity gate: a compliant server stores our bytes verbatim, so the + // returned descriptor hash MUST equal what we uploaded. A mismatch means + // the server transformed/re-encoded the blob, which is fatal for an + // encrypted upload (corrupts the ciphertext). `[INTEGRITY]` marks it so + // the failover loop routes around the server like a hard rejection. + if descriptor.sha256 != hash { + return Err(format!( + "[INTEGRITY] {} transformed the upload (returned {}, expected {})", + server_url, descriptor.sha256, hash, + )); + } + Ok(descriptor.url.to_string()) + } else { + // BUD-02: X-Reason is display-only; body feeds the classifier. + let x_reason = response.headers().get("X-Reason") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + let display = match (error_text.is_empty(), x_reason) { + (false, Some(r)) => format!("{} (X-Reason: {})", error_text, r), + (false, None) => error_text, + (true, Some(r)) => r, + (true, None) => "Unknown error".to_string(), + }; + crate::log_warn!("[Blossom Error] Upload failed with status {}: {}", status, display); + Err(format!("Upload failed with status {}: {}", status, display)) + } +} + +/// Simple upload without progress tracking +pub async fn upload_blob( + signer: T, + server_url: &Url, + file_data: Arc>, + mime_type: Option<&str>, +) -> Result +where + T: NostrSigner, +{ + let upload_url = server_url.join("upload") + .map_err(|e| format!("Invalid server URL: {}", e))?; + + let hash = Sha256Hash::hash(&*file_data); + let total_size = file_data.len() as u64; + + let auth_header = build_auth_header(&signer, hash).await?; + + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth_header); + if let Some(ct) = mime_type { + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))? + ); + } + headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size)); + + // Redirects disabled so a 3xx mid-PUT doesn't re-issue as GET. + let client = crate::net::build_http_client_with_options( + std::time::Duration::from_secs(300), + false, + )?; + + let body_data: Vec = Arc::try_unwrap(file_data) + .unwrap_or_else(|arc| (*arc).clone()); + let response = client + .put(upload_url) + .headers(headers) + .body(body_data) + .send() + .await + .map_err(|e| format!("Upload request failed: {}", e))?; + + // BUD-02: accept any 2xx (200 OK or 201 Created). + let status = response.status(); + if status.is_success() { + let descriptor: BlobDescriptor = response.json().await + .map_err(|e| format!("Failed to parse response: {}", e))?; + // Integrity gate (see upload_attempt): reject a server that returns a + // different hash than we uploaded — it re-encoded the blob. + if descriptor.sha256 != hash { + return Err(format!( + "[INTEGRITY] {} transformed the upload (returned {}, expected {})", + server_url, descriptor.sha256, hash, + )); + } + Ok(descriptor.url.to_string()) + } else { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + Err(format!("Upload failed with status {}: {}", status, error_text)) + } +} + +/// Upload to multiple Blossom servers with failover, in input order. +/// +/// **Does NOT participate in the capability cache.** Used by the +/// marketplace (plaintext mini-app uploads); for high-volume callers +/// prefer `upload_blob_with_progress_and_failover` so they benefit +/// from cache-aware routing. +pub async fn upload_blob_with_failover( + signer: T, + server_urls: Vec, + file_data: Arc>, + mime_type: Option<&str>, +) -> Result +where + T: NostrSigner + Clone, +{ + let mut last_error = String::from("No servers available"); + + for (index, server_url_str) in server_urls.iter().enumerate() { + let server_url = match Url::parse(server_url_str) { + Ok(url) => url, + Err(e) => { + crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e); + last_error = format!("Invalid server URL: {}", e); + continue; + } + }; + + crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}", + index + 1, server_urls.len(), server_url_str); + + match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type).await { + Ok(url) => { + crate::log_info!("[Blossom] Upload successful to: {}", server_url_str); + return Ok(url); + } + Err(e) => { + crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e); + last_error = e; + } + } + } + + Err(format!("All Blossom servers failed. Last error: {}", last_error)) +} + +/// Upload with progress + failover, cache-aware routing, and capability learning. +pub async fn upload_blob_with_progress_and_failover( + signer: T, + server_urls: Vec, + file_data: Arc>, + mime_type: Option<&str>, + is_encrypted: bool, + progress_callback: ProgressCallback, + retry_count: Option, + retry_spacing: Option, + cancel_flag: Option>, +) -> Result +where + T: NostrSigner + Clone, +{ + let mut last_error = String::from("No servers available"); + + // Known-good first, unknown second, MIME-rejected last. Stable within + // tier so the user's BUD-03 trust order wins ties. + let size_bytes = file_data.len() as u64; + let mime_for_routing = mime_type.unwrap_or("application/octet-stream"); + let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes); + // Pin capability writes to the account that started the upload. + let upload_session = crate::state::SessionGuard::capture(); + + for (index, server_url_str) in ranked.iter().enumerate() { + if let Some(ref flag) = cancel_flag { + if flag.load(Ordering::Relaxed) { + return Err("Upload cancelled".to_string()); + } + } + + let server_url = match Url::parse(server_url_str) { + Ok(url) => url, + Err(e) => { + crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e); + last_error = format!("Invalid server URL: {}", e); + continue; + } + }; + + crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}", + index + 1, ranked.len(), server_url_str); + + match upload_blob_with_progress( + signer.clone(), + &server_url, + file_data.clone(), + mime_type, + progress_callback.clone(), + retry_count, + retry_spacing, + cancel_flag.clone(), + ).await { + Ok(url) => { + crate::log_info!("[Blossom] Upload successful to: {}", server_url_str); + if let Err(err) = crate::blossom_capabilities::record_accepted( + server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session, + ) { + crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err); + } + return Ok(url); + } + Err(e) => { + if e == "Upload cancelled" { + return Err(e); + } + crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e); + let status = parse_status_from_error(&e); + // `[INTEGRITY]` = server stored a different hash (transformed the + // blob); route around it exactly like a hard MIME rejection. + if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) { + if let Err(err) = crate::blossom_capabilities::record_rejected_mime( + server_url_str, mime_for_routing, is_encrypted, upload_session, + ) { + crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err); + } + } else if crate::blossom_capabilities::is_size_rejection(status) { + if let Err(err) = crate::blossom_capabilities::record_rejected_size( + server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session, + ) { + crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err); + } + } + // Mid-stream drops aren't cached (too ambiguous); only + // an explicit 413 sets min_rejected_size. + last_error = e; + let _ = progress_callback(Some(0), Some(0)); + } + } + } + + Err(format!("All Blossom servers failed. Last error: {}", last_error)) +} + +// ============================================================================ +// Blossom DELETE — paired with NIP-17 message deletion +// ============================================================================ + +/// Build a BUD-01 DELETE authorization header (kind-24242, verb=delete). +async fn build_delete_auth_header( + signer: &T, + hash: Sha256Hash, +) -> Result +where + T: NostrSigner, +{ + let expiration = Timestamp::now() + std::time::Duration::from_secs(300); + let auth = BlossomAuthorization::new( + "Blossom delete authorization".to_string(), + expiration, + BlossomAuthorizationVerb::Delete, + BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]), + ); + + let auth_event: Event = EventBuilder::blossom_auth(auth) + .sign(signer) + .await + .map_err(|e| format!("Failed to sign auth event: {}", e))?; + + let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json()); + let value = format!("Nostr {}", encoded_auth); + + HeaderValue::try_from(value) + .map_err(|e| format!("Failed to create header value: {}", e)) +} + +/// Delete a blob from a Blossom server. 2xx and 404 both count as +/// success (idempotent: "blob is gone" is the goal). 401/403/5xx and +/// network errors propagate. +pub async fn delete_blob( + signer: T, + server_url: &Url, + hash: Sha256Hash, +) -> Result<(), String> +where + T: NostrSigner + Clone, +{ + let auth_header = build_delete_auth_header(&signer, hash).await?; + + let mut url = server_url.clone(); + // BUD-01 DELETE endpoint: `/`. + url.set_path(&format!("/{}", hash)); + + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth_header); + + let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?; + + let response = client + .delete(url) + .headers(headers) + .send() + .await + .map_err(|e| format!("Blossom DELETE request failed: {}", e))?; + + let status = response.status(); + if status.is_success() || status == StatusCode::NOT_FOUND { + Ok(()) + } else { + let body = response.text().await.unwrap_or_else(|_| "".into()); + // "with status N" phrasing so `parse_status_from_error` can read the + // code (the probe uses it to detect deletion-refusal). 404 already + // counts as success above (blob gone = effectively deleted). + Err(format!("Blossom DELETE failed with status {}: {}", status, body)) + } +} + +/// Parse a Blossom blob URL into (origin, hash) and DELETE that blob. +/// Awaitable single-URL variant of `delete_blobs_best_effort` — caller +/// drives sequencing + per-URL UI feedback. +pub async fn delete_blob_by_url(signer: T, url_str: &str) -> Result<(), String> +where + T: NostrSigner + Clone, +{ + let parsed = Url::parse(url_str) + .map_err(|e| format!("Invalid Blossom URL: {}", e))?; + let last_segment = parsed + .path_segments() + .and_then(|segs| segs.rev().find(|s| !s.is_empty())) + .ok_or_else(|| "Blossom URL has no path segment".to_string())?; + let hash_str = last_segment.split('.').next().unwrap_or(""); + let hash = Sha256Hash::from_str(hash_str) + .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?; + + let mut origin = parsed.clone(); + origin.set_path("/"); + origin.set_query(None); + origin.set_fragment(None); + + crate::log_info!("[Blossom] DELETE {} from {}", hash, origin); + // Hard ceiling — a black-holed server must not hang the caller's + // UI (e.g. the pack creator's "Deleting…" overlay) indefinitely. + // 15s is generous for a healthy server and short enough that a + // misbehaving one fails over to the next blob in a batch quickly. + let timeout = std::time::Duration::from_secs(15); + match tokio::time::timeout(timeout, delete_blob(signer, &origin, hash)).await { + Ok(Ok(())) => { + crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin); + Ok(()) + } + Ok(Err(e)) => { + crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e); + Err(e) + } + Err(_) => { + let msg = format!("DELETE timed out after {}s", timeout.as_secs()); + crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin); + Err(msg) + } + } +} + +/// Fire-and-forget DELETE for each parseable blob URL. Pairs with +/// `delete_own_dm` so removing a NIP-17 file message also removes +/// the ciphertext from the server it was uploaded to. +pub fn delete_blobs_best_effort(signer: T, urls: Vec) +where + T: NostrSigner + Clone + Send + Sync + 'static, +{ + for url_str in urls { + let url = match Url::parse(&url_str) { + Ok(u) => u, + Err(_) => continue, + }; + + // Last non-empty path segment (trailing-slash URLs leave an empty tail). + let last_segment = match url.path_segments() + .and_then(|segs| segs.rev().find(|s| !s.is_empty())) + { + Some(s) => s, + None => continue, + }; + // Strip an optional `.ext` suffix some servers append. + let hash_str = last_segment.split('.').next().unwrap_or(""); + let hash = match Sha256Hash::from_str(hash_str) { + Ok(h) => h, + Err(_) => continue, + }; + + let mut origin = url.clone(); + origin.set_path("/"); + origin.set_query(None); + origin.set_fragment(None); + + let signer = signer.clone(); + tokio::spawn(async move { + if let Err(e) = delete_blob(signer, &origin, hash).await { + crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e); + } + }); + } +} + +/// Probe `(server, application/octet-stream, encrypted=true)` with a +/// 32-byte random blob to learn whether the server accepts the binary +/// uploads Vector produces for chat attachments. Single-shot per +/// (server,mime,encrypted). Successful probes are cleaned up via DELETE. +pub async fn probe_servers_for_octet_stream( + signer: T, + server_urls: Vec, + session: crate::state::SessionGuard, +) -> Result +where + T: NostrSigner + Clone, +{ + use rand::RngCore; + if !session.is_valid() { return Ok(0); } + if server_urls.is_empty() { return Ok(0); } + + const PROBE_MIME: &str = "application/octet-stream"; + let mut payload = vec![0u8; 32]; + rand::thread_rng().fill_bytes(&mut payload[..]); + let payload = Arc::new(payload); + let payload_size = payload.len() as u64; + + let mut probed = 0usize; + for server_url_str in &server_urls { + if !session.is_valid() { return Ok(probed); } + if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) { + continue; + } + let parsed = match Url::parse(server_url_str) { + Ok(u) => u, + Err(_) => continue, + }; + // 4s per-server budget bounds worst-case probe pass. + let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(())); + match tokio::time::timeout( + std::time::Duration::from_secs(4), + upload_blob_with_progress( + signer.clone(), + &parsed, + payload.clone(), + Some(PROBE_MIME), + no_op_progress, + Some(0), + None, + None, + ), + ).await { + Ok(Ok(url)) => { + // Race-guard: server may have been disabled/removed + // between spawn and now (purge_server already cleared). + if !crate::blossom_servers::is_enabled_server(server_url_str) { + if let Some(hash) = extract_hash_from_blossom_url(&url) { + let _ = delete_blob(signer.clone(), &parsed, hash).await; + } + continue; + } + // Reaching here means the upload succeeded AND the returned hash + // matched (upload_attempt's integrity gate) — so the server + // accepts our encrypted type and stores it verbatim. Final gate: + // it must honor BUD-01 deletion, else removing a message can't + // remove its blob. The probe blob is deleted either way (cleanup); + // a 4s budget bounds the wait, and only a definitive refusal + // (403/405/501) sinks the server — transient failures stay optimistic. + let delete_result = match extract_hash_from_blossom_url(&url) { + Some(hash) => tokio::time::timeout( + std::time::Duration::from_secs(4), + delete_blob(signer.clone(), &parsed, hash), + ).await.ok(), + None => None, + }; + let refuses_deletion = matches!( + &delete_result, + Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)), + ); + if refuses_deletion { + if let Err(err) = crate::blossom_capabilities::record_rejected_mime( + server_url_str, PROBE_MIME, true, session, + ) { + crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err); + } + probed += 1; + crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str); + } else { + if let Err(e) = crate::blossom_capabilities::record_accepted( + server_url_str, PROBE_MIME, true, payload_size, session, + ) { + crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e); + } + probed += 1; + crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str); + } + } + Ok(Err(e)) => { + let status = parse_status_from_error(&e); + // `[INTEGRITY]` = the server accepted but transformed our probe + // blob; treat it as unsuitable, same as a hard MIME rejection. + if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) { + if !crate::blossom_servers::is_enabled_server(server_url_str) { + continue; + } + if let Err(err) = crate::blossom_capabilities::record_rejected_mime( + server_url_str, PROBE_MIME, true, session, + ) { + crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err); + } + probed += 1; + crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e); + } else { + // Transient — leave reputation unchanged so we re-probe later. + crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e); + } + } + Err(_) => { + crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str); + } + } + } + Ok(probed) +} + +/// Parse the sha256 out of `/[.][/]`. Skips an +/// empty trailing segment when the URL came back with a trailing slash. +fn extract_hash_from_blossom_url(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?; + let stem = last.split('.').next()?; + Sha256Hash::from_str(stem).ok() +} + +/// Extract the HTTP status from an error string. Anchored to the +/// `"with status NNN"` shape produced by `upload_blob_with_progress` +/// so unrelated `status` substrings don't false-match. +fn parse_status_from_error(msg: &str) -> Option { + let key = "with status "; + let i = msg.find(key)?; + let tail = &msg[i + key.len()..]; + let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().ok() +} + +#[cfg(test)] +mod parse_status_tests { + use super::parse_status_from_error; + + #[test] + fn extracts_status_code() { + assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500)); + assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413)); + assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415)); + // Cloudflare gateway timeouts render as "" — the failover branch relies + // on this still parsing to the numeric code. + assert_eq!(parse_status_from_error("Upload failed with status 524 : gateway"), Some(524)); + } + + #[test] + fn returns_none_when_absent() { + assert_eq!(parse_status_from_error("network error: timeout"), None); + } +} + +#[cfg(test)] +mod hash_extract_tests { + use super::extract_hash_from_blossom_url; + + const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + + #[test] + fn plain_url() { + let url = format!("https://srv.example/{}", HASH_HEX); + assert!(extract_hash_from_blossom_url(&url).is_some()); + } + + #[test] + fn with_extension() { + let url = format!("https://srv.example/{}.jpg", HASH_HEX); + assert!(extract_hash_from_blossom_url(&url).is_some()); + } + + #[test] + fn trailing_slash_still_resolves() { + // Some servers append a trailing slash to the descriptor URL. + let url = format!("https://srv.example/{}/", HASH_HEX); + assert!(extract_hash_from_blossom_url(&url).is_some()); + } + + #[test] + fn malformed_returns_none() { + assert!(extract_hash_from_blossom_url("https://srv.example/").is_none()); + assert!(extract_hash_from_blossom_url("not a url").is_none()); + assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none()); + } + + #[test] + fn x_sha256_simd_hex_matches_lowerhex() { + use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash}; + // The X-SHA-256 header swapped format!("{:x}") for the SIMD encoder; they MUST agree + // byte-for-byte (sha256::Hash displays in forward order — a reversed-display hash type would + // silently corrupt the upload header). + let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check"); + assert_eq!( + crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()), + format!("{:x}", hash), + ); + } +} diff --git a/crates/vector-core/src/blossom_capabilities.rs b/crates/vector-core/src/blossom_capabilities.rs new file mode 100644 index 00000000..6d137a21 --- /dev/null +++ b/crates/vector-core/src/blossom_capabilities.rs @@ -0,0 +1,549 @@ +//! Per-(server, mime, encrypted) Blossom capability cache and routing. +//! +//! Uploads report outcomes here; `rank_servers()` reorders the enabled +//! list so known-good servers are tried first. + +use std::collections::HashMap; +use serde::Serialize; + +pub const OUTCOME_ACCEPTED: u8 = 1; +pub const OUTCOME_REJECTED_MIME: u8 = 2; +/// Seeded by a 413-only rejection (no successful upload yet). Kept +/// distinct from ACCEPTED so the UI doesn't claim an empty accepted state. +pub const OUTCOME_SIZE_ONLY: u8 = 3; + +/// Rows older than this are routed as "unknown" and re-probed. Server +/// policies drift (limit bumps, MIME allow-list edits) so we refresh. +pub const STALE_AFTER_SECS: i64 = 4 * 24 * 3600; + +#[derive(Clone, Debug, Serialize)] +pub struct CapabilityEntry { + pub mime_type: String, + /// Encrypted ciphertext rarely passes a server's content-sniff even + /// when the declared MIME is allowed. Rows split on this flag so the + /// two contexts learn independently. + pub is_encrypted: bool, + pub outcome: u8, // 1=accepted, 2=rejected_mime, 3=size_only + pub max_accepted_size: u64, + /// Smallest size we've seen this server reject with HTTP 413, if any. + pub min_rejected_size: Option, + pub updated_at: i64, +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64).unwrap_or(0) +} + +fn norm_url(url: &str) -> String { + url.trim().trim_end_matches('/').to_lowercase() +} + +// ============================================================================ +// Storage +// ============================================================================ + +/// Record a successful upload. Bumps `max_accepted_size`; clears +/// `min_rejected_size` if reality contradicts it (the old rejection +/// was a flake, not a policy). `session` pins the write to the +/// account that started the upload so a mid-flight swap can't bleed. +pub fn record_accepted( + server_url: &str, + mime_type: &str, + is_encrypted: bool, + size_bytes: u64, + session: crate::state::SessionGuard, +) -> Result<(), String> { + if !session.is_valid() { return Ok(()); } + let conn = crate::db::get_write_connection_guard_static()?; + if !session.is_valid() { return Ok(()); } + let server = norm_url(server_url); + let mime = mime_type.to_lowercase(); + let enc = if is_encrypted { 1i64 } else { 0i64 }; + let now = now_secs(); + conn.execute( + "INSERT INTO blossom_server_capabilities + (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET + outcome = ?4, + max_accepted_size = MAX(max_accepted_size, ?5), + min_rejected_size = CASE + WHEN min_rejected_size IS NOT NULL AND min_rejected_size <= ?5 + THEN NULL + ELSE min_rejected_size + END, + updated_at = ?6", + rusqlite::params![server, mime, enc, OUTCOME_ACCEPTED as i64, size_bytes as i64, now], + ).map_err(|e| format!("Failed to record accepted capability: {}", e))?; + Ok(()) +} + +/// Mark this `(server, mime, encrypted)` triple as MIME-rejected. +/// Future uploads route around the server. +pub fn record_rejected_mime( + server_url: &str, + mime_type: &str, + is_encrypted: bool, + session: crate::state::SessionGuard, +) -> Result<(), String> { + if !session.is_valid() { return Ok(()); } + let conn = crate::db::get_write_connection_guard_static()?; + if !session.is_valid() { return Ok(()); } + let server = norm_url(server_url); + let mime = mime_type.to_lowercase(); + let enc = if is_encrypted { 1i64 } else { 0i64 }; + let now = now_secs(); + conn.execute( + "INSERT INTO blossom_server_capabilities + (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at) + VALUES (?1, ?2, ?3, ?4, 0, ?5) + ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET + outcome = ?4, + updated_at = ?5", + rusqlite::params![server, mime, enc, OUTCOME_REJECTED_MIME as i64, now], + ).map_err(|e| format!("Failed to record rejected capability: {}", e))?; + Ok(()) +} + +/// Record an HTTP 413. Tracks the smallest rejected size; combined with +/// `max_accepted_size` this gives pre-flight "too large" feedback. +/// Outcome is `SIZE_ONLY` (not `REJECTED_MIME`) — smaller blobs of the +/// same MIME may still succeed. +pub fn record_rejected_size( + server_url: &str, + mime_type: &str, + is_encrypted: bool, + size_bytes: u64, + session: crate::state::SessionGuard, +) -> Result<(), String> { + if !session.is_valid() { return Ok(()); } + let conn = crate::db::get_write_connection_guard_static()?; + if !session.is_valid() { return Ok(()); } + let server = norm_url(server_url); + let mime = mime_type.to_lowercase(); + let enc = if is_encrypted { 1i64 } else { 0i64 }; + let now = now_secs(); + // ON CONFLICT keeps any existing `outcome` (especially ACCEPTED) — a + // 413 above the known accepted size still leaves smaller blobs viable. + conn.execute( + "INSERT INTO blossom_server_capabilities + (server_url, mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at) + VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6) + ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET + min_rejected_size = MIN(COALESCE(min_rejected_size, ?5), ?5), + updated_at = ?6", + rusqlite::params![server, mime, enc, OUTCOME_SIZE_ONLY as i64, size_bytes as i64, now], + ).map_err(|e| format!("Failed to record size rejection: {}", e))?; + Ok(()) +} + +/// True iff a row exists for `(server, mime, encrypted)` and is younger +/// than `STALE_AFTER_SECS`. Used by the probe scheduler to skip rows +/// we already have current data for. +pub fn has_fresh_capability_for(server_url: &str, mime_type: &str, is_encrypted: bool) -> bool { + let conn = match crate::db::get_db_connection_guard_static() { + Ok(c) => c, + Err(_) => return false, + }; + let server = norm_url(server_url); + let mime = mime_type.to_lowercase(); + let enc = if is_encrypted { 1i64 } else { 0i64 }; + let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS); + conn.query_row( + "SELECT 1 FROM blossom_server_capabilities + WHERE server_url = ?1 AND mime_type = ?2 AND is_encrypted = ?3 AND updated_at >= ?4", + rusqlite::params![server, mime, enc, cutoff], + |_| Ok(()), + ).is_ok() +} + +/// Drop every cached row for `server_url`. Called on hard-remove so a +/// later re-add starts with a clean slate. +pub fn purge_server(server_url: &str) -> Result { + let conn = crate::db::get_write_connection_guard_static()?; + let server = norm_url(server_url); + let n = conn.execute( + "DELETE FROM blossom_server_capabilities WHERE server_url = ?1", + rusqlite::params![server], + ).map_err(|e| format!("Failed to purge capabilities for {}: {}", server, e))?; + Ok(n) +} + +/// All rows for `server_url`, most-recent first. Renders the info dialog. +pub fn list_for_server(server_url: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let server = norm_url(server_url); + let mut stmt = conn.prepare( + "SELECT mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at + FROM blossom_server_capabilities + WHERE server_url = ?1 + ORDER BY updated_at DESC" + ).map_err(|e| format!("Prepare failed: {}", e))?; + let rows = stmt.query_map(rusqlite::params![server], |row| { + let enc: i64 = row.get(1)?; + let min_rej: Option = row.get(4)?; + Ok(CapabilityEntry { + mime_type: row.get(0)?, + is_encrypted: enc != 0, + outcome: row.get::<_, i64>(2)? as u8, + max_accepted_size: row.get::<_, i64>(3)? as u64, + min_rejected_size: min_rej.map(|v| v as u64), + updated_at: row.get(5)?, + }) + }).map_err(|e| format!("Query failed: {}", e))?; + let mut out = Vec::new(); + for r in rows { + if let Ok(entry) = r { out.push(entry); } + } + Ok(out) +} + +// ============================================================================ +// Routing +// ============================================================================ + +/// Per-server snapshot used for tier classification. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CapabilityState { + pub outcome: u8, + pub max_accepted_size: u64, + pub min_rejected_size: Option, +} + +/// Pure tier-classification (DB-free, unit-testable). Reorders into +/// known-good → unknown → too-large → MIME-rejected, stable within tier. +pub fn classify( + cache: &HashMap, + servers: Vec, + size_bytes: u64, +) -> Vec { + let mut known_good = Vec::new(); + let mut unknown = Vec::new(); + let mut too_large = Vec::new(); + let mut mime_rejected = Vec::new(); + for s in servers { + let key = norm_url(&s); + match cache.get(&key) { + Some(st) if st.outcome == OUTCOME_REJECTED_MIME => mime_rejected.push(s), + Some(st) => { + // At or above the known 413 ceiling, demote behind unknown. + if let Some(min_rej) = st.min_rejected_size { + if size_bytes >= min_rej { + too_large.push(s); + continue; + } + } + if size_bytes <= st.max_accepted_size { + known_good.push(s); + } else { + unknown.push(s); + } + } + None => unknown.push(s), + } + } + known_good.extend(unknown); + known_good.extend(too_large); + known_good.extend(mime_rejected); + known_good +} + +/// Reorder `servers` for an upload of `(mime, encrypted, size_bytes)`. +pub fn rank_servers(servers: Vec, mime: &str, is_encrypted: bool, size_bytes: u64) -> Vec { + if servers.is_empty() { return servers; } + let cache = load_cache_for(&servers, mime, is_encrypted).unwrap_or_default(); + classify(&cache, servers, size_bytes) +} + +/// Pre-flight check: is there any enabled server we haven't already +/// learned will reject this size/MIME/context? Unknown servers count +/// as "likely accepts" so we stay optimistic. +pub fn any_server_likely_accepts(servers: &[String], mime: &str, is_encrypted: bool, size_bytes: u64) -> bool { + if servers.is_empty() { return false; } + let cache = match load_cache_for(servers, mime, is_encrypted) { Ok(c) => c, Err(_) => return true }; + for s in servers { + let key = norm_url(s); + match cache.get(&key) { + Some(st) if st.outcome == OUTCOME_REJECTED_MIME => continue, + Some(st) => { + if let Some(min_rej) = st.min_rejected_size { + if size_bytes >= min_rej { continue; } + } + return true; + } + None => return true, + } + } + false +} + +fn load_cache_for(servers: &[String], mime: &str, is_encrypted: bool) -> Result, String> { + if servers.is_empty() { return Ok(HashMap::new()); } + let conn = crate::db::get_db_connection_guard_static()?; + let mime_lower = mime.to_lowercase(); + let enc: i64 = if is_encrypted { 1 } else { 0 }; + // Stale rows route as unknown. + let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS); + // rusqlite doesn't accept slices in IN — build the clause manually. + let placeholders = servers.iter().enumerate() + .map(|(i, _)| format!("?{}", i + 4)).collect::>().join(","); + let sql = format!( + "SELECT server_url, outcome, max_accepted_size, min_rejected_size + FROM blossom_server_capabilities + WHERE mime_type = ?1 AND is_encrypted = ?2 AND updated_at >= ?3 AND server_url IN ({})", + placeholders, + ); + let mut stmt = conn.prepare(&sql).map_err(|e| format!("Prepare failed: {}", e))?; + let cutoff_param: i64 = cutoff; + let mut params: Vec<&dyn rusqlite::ToSql> = vec![&mime_lower, &enc, &cutoff_param]; + let normalized: Vec = servers.iter().map(|s| norm_url(s)).collect(); + for n in normalized.iter() { params.push(n); } + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| { + let url: String = row.get(0)?; + let outcome: i64 = row.get(1)?; + let max: i64 = row.get(2)?; + let min_rej: Option = row.get(3)?; + Ok((url, CapabilityState { + outcome: outcome as u8, + max_accepted_size: max as u64, + min_rejected_size: min_rej.map(|v| v as u64), + })) + }).map_err(|e| format!("Query failed: {}", e))?; + let mut out = HashMap::new(); + for r in rows { + if let Ok((url, state)) = r { + out.insert(url, state); + } + } + Ok(out) +} + +// ============================================================================ +// Outcome classification +// ============================================================================ + +/// HTTP 413 = "blob exceeds size cap". Drives `min_rejected_size`. +pub fn is_size_rejection(http_status: Option) -> bool { + matches!(http_status, Some(413)) +} + +/// Classify an upload error as a permanent MIME rejection. +/// +/// 415 is BUD-02's canonical signal. 401/402 are also treated permanent +/// (Vector's auth is fixed-shape, so 401 won't change; 402 means paid +/// server, we don't pay). 408/429/413/409 are explicitly NOT permanent. +/// For 5xx and other 4xx with no clear status signal we fall back to a +/// narrow body-keyword check — non-compliant servers (e.g. nostrcheck +/// returning `500 "could not be processed"`, blossom.band's 400 sniff +/// mismatch) would otherwise force a re-discover on every upload. +pub fn is_mime_rejection(http_status: Option, error_msg: &str) -> bool { + if matches!(http_status, Some(415)) { return true; } + if matches!(http_status, Some(401)) { return true; } + if matches!(http_status, Some(402)) { return true; } + if matches!(http_status, Some(413) | Some(409)) { return false; } + if matches!(http_status, Some(408) | Some(429)) { return false; } + if let Some(s) = http_status { + if (500..=504).contains(&s) { + // Only the "could not process" idiom is permanent on 5xx — + // everything else stays transient (could be a temporary outage). + let lower = error_msg.to_ascii_lowercase(); + return lower.contains("could not be processed") + || lower.contains("cannot be processed"); + } + } + // Other 4xx or unknown status: substring-match body hints. + let lower = error_msg.to_ascii_lowercase(); + let hints = [ + "could not be processed", + "cannot be processed", + "unsupported", + "file type", + "mime", + "invalid file", + "not allowed", + "does not match", + "doesn't match", + "content-type", + ]; + hints.iter().any(|h| lower.contains(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cache_of(entries: &[(&str, u8, u64)]) -> HashMap { + entries.iter() + .map(|(url, o, max)| (norm_url(url), CapabilityState { + outcome: *o, + max_accepted_size: *max, + min_rejected_size: None, + })) + .collect() + } + + fn cache_with_size_cap(entries: &[(&str, u8, u64, u64)]) -> HashMap { + entries.iter() + .map(|(url, o, max, min_rej)| (norm_url(url), CapabilityState { + outcome: *o, + max_accepted_size: *max, + min_rejected_size: Some(*min_rej), + })) + .collect() + } + + #[test] + fn norm_url_handles_case_and_trailing_slash() { + assert_eq!(norm_url("HTTPS://Example.COM/"), "https://example.com"); + assert_eq!(norm_url(" https://example.com "), "https://example.com"); + assert_eq!(norm_url("https://example.com"), "https://example.com"); + } + + #[test] + fn classify_empty_input_returns_empty() { + let cache = HashMap::new(); + assert!(classify(&cache, vec![], 1024).is_empty()); + } + + #[test] + fn classify_all_unknown_preserves_order() { + let cache = HashMap::new(); + let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()]; + assert_eq!(classify(&cache, servers.clone(), 1024), servers); + } + + #[test] + fn classify_known_good_floats_to_top() { + let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]); + let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()]; + assert_eq!(classify(&cache, servers, 5_000), vec!["https://b", "https://a", "https://c"]); + } + + #[test] + fn classify_known_good_falls_to_unknown_when_over_size_ceiling() { + let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]); + let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()]; + let out = classify(&cache, servers, 20_000); + assert_eq!(out, vec!["https://a", "https://b", "https://c"]); + } + + #[test] + fn classify_mime_rejected_sinks_to_bottom() { + let cache = cache_of(&[("https://b", OUTCOME_REJECTED_MIME, 0)]); + let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()]; + let out = classify(&cache, servers, 1024); + assert_eq!(out, vec!["https://a", "https://c", "https://b"]); + } + + #[test] + fn classify_mixed_tiers_full_ordering() { + let cache = cache_of(&[ + ("https://b", OUTCOME_ACCEPTED, 10_000), + ("https://c", OUTCOME_REJECTED_MIME, 0), + ("https://d", OUTCOME_ACCEPTED, 1_000), + ]); + let servers = vec![ + "https://a".to_string(), "https://b".to_string(), + "https://c".to_string(), "https://d".to_string(), + ]; + assert_eq!( + classify(&cache, servers, 5_000), + vec!["https://b", "https://a", "https://d", "https://c"], + ); + } + + #[test] + fn classify_demotes_servers_above_known_size_ceiling() { + let cache = cache_with_size_cap(&[("https://b", OUTCOME_ACCEPTED, 10_000, 50_000)]); + let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()]; + let out = classify(&cache, servers, 60_000); + assert_eq!(out, vec!["https://a", "https://c", "https://b"]); + } + + #[test] + fn any_server_likely_accepts_smoke_test() { + // Full fn needs DB access; this just sanity-checks the data shape. + let cache = cache_with_size_cap(&[ + ("https://a", OUTCOME_ACCEPTED, 1_000, 10_000), + ("https://b", OUTCOME_ACCEPTED, 1_000, 10_000), + ]); + for (_, st) in &cache { + assert!(st.min_rejected_size.unwrap() <= 50_000); + } + } + + #[test] + fn classify_keys_are_normalized_against_cache() { + let cache = cache_of(&[("https://b.example.com", OUTCOME_ACCEPTED, 10_000)]); + let servers = vec!["https://a".to_string(), "https://B.Example.com/".to_string()]; + let out = classify(&cache, servers, 5_000); + assert_eq!(out, vec!["https://B.Example.com/", "https://a"]); + } + + #[test] + fn mime_rejection_415_is_always_a_reject() { + assert!(is_mime_rejection(Some(415), "")); + } + + #[test] + fn mime_rejection_413_and_409_never_mime_regardless_of_body() { + assert!(!is_mime_rejection(Some(413), "Payload Too Large mime")); + assert!(!is_mime_rejection(Some(409), "Conflict file type")); + } + + #[test] + fn mime_rejection_401_treated_as_permanent() { + assert!(is_mime_rejection(Some(401), "Unauthorized")); + assert!(is_mime_rejection(Some(401), "")); + } + + #[test] + fn mime_rejection_5xx_with_body_hint_recorded() { + // nostrcheck-shape: 500 + "could not be processed". + assert!(is_mime_rejection( + Some(500), + r#"Upload failed with status 500 Internal Server Error: {"status":"error","message":"File could not be processed"}"#, + )); + } + + #[test] + fn mime_rejection_content_type_sniff_mismatch_recorded() { + // blossom.band-shape: 400 + body says the bytes didn't match the declared MIME. + assert!(is_mime_rejection( + Some(400), + "Upload failed with status 400 Bad Request: Content-Type header does not match the file content, expected application/json", + )); + } + + #[test] + fn mime_rejection_transient_5xx_without_hint_not_recorded() { + assert!(!is_mime_rejection(Some(500), "Internal Server Error")); + assert!(!is_mime_rejection(Some(503), "service unavailable")); + } + + #[test] + fn mime_rejection_transient_5xx_with_unrelated_body_keywords_not_recorded() { + // "file type" / "content-type" in a transient body must not demote permanently. + assert!(!is_mime_rejection(Some(503), "file type detection service down")); + assert!(!is_mime_rejection(Some(429), "rate limit reached for content-type uploads")); + } + + #[test] + fn mime_rejection_payment_required_treated_as_permanent() { + assert!(is_mime_rejection(Some(402), "")); + assert!(is_mime_rejection(Some(402), "Payment Required")); + } + + #[test] + fn mime_rejection_no_status_with_body_hint_recorded() { + assert!(is_mime_rejection(None, "Unsupported file type")); + } + + #[test] + fn mime_rejection_no_status_no_body_hint_not_recorded() { + assert!(!is_mime_rejection(None, "network error")); + assert!(!is_mime_rejection(None, "timeout")); + } +} diff --git a/crates/vector-core/src/blossom_servers.rs b/crates/vector-core/src/blossom_servers.rs new file mode 100644 index 00000000..ad9b1b46 --- /dev/null +++ b/crates/vector-core/src/blossom_servers.rs @@ -0,0 +1,454 @@ +//! BUD-03 user blossom server list (kind 10063) — store, merge, publish, fetch. +//! +//! Two SQL settings rows back the user's preferences: +//! * `custom_blossom_servers` — `Vec` JSON +//! * `disabled_default_blossom_servers` — `Vec` JSON +//! +//! Resolution order (BUD-03 trust order): enabled defaults, then enabled customs. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::collections::HashSet; + +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::state::nostr_client; + +/// Default servers in trust order (first = first try). All verified to +/// accept Vector's encrypted octet-stream uploads at common sizes. +pub const DEFAULT_BLOSSOM_SERVERS: &[&str] = &[ + "https://blossom.ditto.pub", + "https://blossom.primal.net", + "https://nostr.download", + "https://blossom.data.haus", +]; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CustomBlossomServer { + pub url: String, + pub enabled: bool, +} + +/// Validate + canonicalize a server URL: trim, strip trailing slash, +/// auto-prefix `https://` on bare domains, enforce http(s) + non-empty host. +pub fn validate_url(url: &str) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err("URL cannot be empty".to_string()); + } + let with_scheme = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("https://{}", trimmed) + }; + let parsed = ::url::Url::parse(&with_scheme) + .map_err(|e| format!("Invalid URL: {}", e))?; + let scheme = parsed.scheme(); + if scheme != "https" && scheme != "http" { + return Err("URL must use https:// or http://".to_string()); + } + if parsed.host_str().map_or(true, str::is_empty) { + return Err("URL must include a host".to_string()); + } + Ok(with_scheme.trim_end_matches('/').to_string()) +} + +/// One row in the frontend's Media Servers list. +#[derive(Serialize, Clone, Debug)] +pub struct BlossomServerInfo { + pub url: String, + pub is_default: bool, + pub is_custom: bool, + pub enabled: bool, +} + +// ============================================================================ +// Storage +// ============================================================================ + +pub fn load_custom_blossom_servers() -> Result, String> { + match crate::db::get_sql_setting("custom_blossom_servers".to_string()) + .ok().flatten() + { + Some(json) => serde_json::from_str(&json) + .map_err(|e| format!("Failed to parse custom_blossom_servers: {}", e)), + None => Ok(Vec::new()), + } +} + +pub fn save_custom_blossom_servers(servers: &[CustomBlossomServer]) -> Result<(), String> { + let json = serde_json::to_string(servers) + .map_err(|e| format!("Failed to serialize custom blossom servers: {}", e))?; + crate::db::set_sql_setting("custom_blossom_servers".to_string(), json) +} + +pub fn load_disabled_default_blossom_servers() -> Result, String> { + match crate::db::get_sql_setting("disabled_default_blossom_servers".to_string()) + .ok().flatten() + { + Some(json) => serde_json::from_str(&json) + .map_err(|e| format!("Failed to parse disabled_default_blossom_servers: {}", e)), + None => Ok(Vec::new()), + } +} + +pub fn save_disabled_default_blossom_servers(urls: &[String]) -> Result<(), String> { + let json = serde_json::to_string(urls) + .map_err(|e| format!("Failed to serialize disabled default blossom servers: {}", e))?; + crate::db::set_sql_setting("disabled_default_blossom_servers".to_string(), json) +} + +// ============================================================================ +// Resolver — enabled customs (first) + defaults (minus disabled) +// ============================================================================ + +pub fn is_default_server(url: &str) -> bool { + let norm = url.trim().trim_end_matches('/').to_lowercase(); + DEFAULT_BLOSSOM_SERVERS.iter() + .any(|d| d.trim_end_matches('/').to_lowercase() == norm) +} + +/// Race-guard for in-flight probes: true if `url` is in the currently +/// enabled list at this moment. +pub fn is_enabled_server(url: &str) -> bool { + let target = url.trim().trim_end_matches('/').to_lowercase(); + compute_enabled_servers().iter() + .any(|s| s.trim_end_matches('/').to_lowercase() == target) +} + +pub fn compute_enabled_servers() -> Vec { + let disabled = load_disabled_default_blossom_servers().unwrap_or_default(); + let disabled_lower: HashSet = disabled.iter() + .map(|s| s.trim().trim_end_matches('/').to_lowercase()) + .collect(); + + // Enabled custom servers are tried FIRST — a user who adds their own + // server wants it used (self-hosted / trusted). The capability ranker + // (`rank_servers`) then sinks any that fail validation (won't accept our + // encrypted type, transform the bytes, or refuse deletion) below the + // defaults, so a bad custom quietly falls through to ditto/etc. + let mut out: Vec = Vec::new(); + let customs = load_custom_blossom_servers().unwrap_or_default(); + for c in customs { + if c.enabled { + out.push(c.url); + } + } + for d in DEFAULT_BLOSSOM_SERVERS { + let key = d.trim_end_matches('/').to_lowercase(); + if !disabled_lower.contains(&key) { + out.push((*d).to_string()); + } + } + out +} + +/// All rows for the frontend (defaults then customs, including disabled). +pub fn list_all_servers() -> Vec { + let disabled = load_disabled_default_blossom_servers().unwrap_or_default(); + let disabled_lower: HashSet = disabled.iter() + .map(|s| s.trim().trim_end_matches('/').to_lowercase()) + .collect(); + + let mut out: Vec = Vec::new(); + for d in DEFAULT_BLOSSOM_SERVERS { + let key = d.trim_end_matches('/').to_lowercase(); + out.push(BlossomServerInfo { + url: (*d).to_string(), + is_default: true, + is_custom: false, + enabled: !disabled_lower.contains(&key), + }); + } + for c in load_custom_blossom_servers().unwrap_or_default() { + out.push(BlossomServerInfo { + url: c.url, + is_default: false, + is_custom: true, + enabled: c.enabled, + }); + } + out +} + +/// Refresh the in-memory `BLOSSOM_SERVERS` cache. Call after edits + on login. +pub fn refresh_cache() { + let merged = compute_enabled_servers(); + let mutex = crate::state::BLOSSOM_SERVERS + .get_or_init(|| std::sync::Mutex::new(merged.clone())); + if let Ok(mut guard) = mutex.lock() { + *guard = merged; + } +} + +// ============================================================================ +// BUD-03 publish (kind 10063) +// ============================================================================ + +/// Publish the resolved enabled list (defaults + customs, in trust order) +/// as a BUD-03 kind 10063 replaceable event. Peers using our list as a +/// fallback need to see every server we use, not just the customs. +pub async fn publish_blossom_servers(client: &Client) -> Result<(), String> { + let servers = compute_enabled_servers(); + let mut builder = EventBuilder::new(Kind::Custom(10063), ""); + for url in &servers { + builder = builder.tag(Tag::custom(TagKind::custom("server"), vec![url.clone()])); + } + client.send_event_builder(builder).await + .map_err(|e| format!("Failed to publish blossom servers: {}", e))?; + crate::log_info!("[BlossomServers] Published kind 10063 with {} server(s)", servers.len()); + Ok(()) +} + +static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0); + +/// Debounced republish: rapid edits coalesce; mid-window session swap aborts. +/// Retries once on failure (5s backoff): a stale event on the network would +/// otherwise let `fetch_and_merge_own_list` overwrite the local prefs on +/// the next boot. +pub fn republish_blossom_servers_debounced() { + let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1; + let session = crate::state::SessionGuard::capture(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(800)).await; + if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; } + if !session.is_valid() { return; } + let client = match nostr_client() { + Some(c) => c, + None => return, + }; + if let Err(e) = publish_blossom_servers(&client).await { + crate::log_warn!("[BlossomServers] Republish failed: {} (retrying in 5s)", e); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; } + if !session.is_valid() { return; } + if let Err(e2) = publish_blossom_servers(&client).await { + crate::log_warn!("[BlossomServers] Republish retry failed: {}", e2); + } + } + }); +} + +// ============================================================================ +// BUD-03 fetch — pull our own kind 10063, merge into customs +// ============================================================================ + +/// Pure merge: append novel URLs from `incoming` (validated, normalized) +/// into `customs` as enabled. Existing rows are never removed or reordered. +pub fn merge_urls_into_customs( + incoming: &[String], + mut customs: Vec, +) -> (Vec, usize) { + let mut known_lower: HashSet = customs.iter() + .map(|c| c.url.trim_end_matches('/').to_lowercase()) + .collect(); + for d in DEFAULT_BLOSSOM_SERVERS { + known_lower.insert(d.trim_end_matches('/').to_lowercase()); + } + + let mut added = 0usize; + for raw in incoming { + let normalized = match validate_url(raw) { + Ok(u) => u, + Err(_) => continue, + }; + let key = normalized.to_lowercase(); + if known_lower.contains(&key) { continue; } + customs.push(CustomBlossomServer { url: normalized, enabled: true }); + known_lower.insert(key); + added += 1; + } + (customs, added) +} + +/// Fetch our latest kind 10063 and reconcile: append unknown customs, +/// follow the originating device's default enable/disable choices. +/// `session` gates writes against a mid-fetch account swap. +pub async fn fetch_and_merge_own_list( + client: &Client, + my_pubkey: PublicKey, + session: crate::state::SessionGuard, +) -> Result { + let filter = Filter::new() + .author(my_pubkey) + .kind(Kind::Custom(10063)) + .limit(1); + let events = client + .fetch_events(filter, std::time::Duration::from_secs(8)) + .await + .map_err(|e| format!("Failed to fetch kind 10063: {}", e))?; + + if !session.is_valid() { return Ok(0); } + + let event = match events.into_iter().max_by_key(|e| e.created_at) { + Some(e) => e, + None => { + crate::log_debug!("[BlossomServers] No kind 10063 found for own pubkey"); + return Ok(0); + } + }; + + let urls_from_event: Vec = event.tags.iter() + .filter_map(|t| { + if t.kind() == TagKind::custom("server") { + t.content().map(|s| s.to_string()) + } else { + None + } + }) + .collect(); + + // Event presence means the user has published preferences somewhere. + // A default's absence from the list = explicit disable on that + // device. (No event at all takes the early return above.) + let urls_lower: HashSet = urls_from_event.iter() + .map(|u| u.trim().trim_end_matches('/').to_lowercase()) + .collect(); + let mut disabled = load_disabled_default_blossom_servers().unwrap_or_default(); + let mut defaults_changed = false; + for d in DEFAULT_BLOSSOM_SERVERS { + let key = d.trim_end_matches('/').to_lowercase(); + let in_event = urls_lower.contains(&key); + let currently_disabled = disabled.iter() + .any(|s| s.trim_end_matches('/').to_lowercase() == key); + if in_event && currently_disabled { + disabled.retain(|s| s.trim_end_matches('/').to_lowercase() != key); + defaults_changed = true; + } else if !in_event && !currently_disabled { + disabled.push(d.to_string()); + defaults_changed = true; + } + } + + let customs = load_custom_blossom_servers().unwrap_or_default(); + let (new_customs, customs_added) = merge_urls_into_customs(&urls_from_event, customs); + + let any_changes = customs_added > 0 || defaults_changed; + if any_changes { + if !session.is_valid() { return Ok(0); } + if defaults_changed { + save_disabled_default_blossom_servers(&disabled)?; + } + if customs_added > 0 { + save_custom_blossom_servers(&new_customs)?; + } + if !session.is_valid() { return Ok(customs_added); } + refresh_cache(); + crate::traits::emit_event("blossom_servers_updated", &()); + crate::log_info!( + "[BlossomServers] Merged kind 10063: {} custom server(s) added, defaults reconciled (disabled now {})", + customs_added, disabled.len(), + ); + } + Ok(customs_added) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn custom(url: &str, enabled: bool) -> CustomBlossomServer { + CustomBlossomServer { url: url.to_string(), enabled } + } + + #[test] + fn validate_url_strips_trailing_slash_and_whitespace() { + assert_eq!(validate_url(" https://example.com/ ").unwrap(), "https://example.com"); + assert_eq!(validate_url("https://example.com").unwrap(), "https://example.com"); + } + + #[test] + fn validate_url_auto_prefixes_bare_domain_with_https() { + assert_eq!(validate_url("blossom.band").unwrap(), "https://blossom.band"); + assert_eq!(validate_url(" blossom.primal.net/ ").unwrap(), "https://blossom.primal.net"); + } + + #[test] + fn validate_url_keeps_explicit_http_scheme() { + assert_eq!(validate_url("http://localhost:8080").unwrap(), "http://localhost:8080"); + } + + #[test] + fn validate_url_rejects_non_http_schemes() { + assert!(validate_url("ftp://example.com").is_err()); + assert!(validate_url("wss://example.com").is_err()); + assert!(validate_url("javascript:alert(1)").is_err()); + } + + #[test] + fn validate_url_rejects_empty_or_hostless() { + assert!(validate_url("").is_err()); + assert!(validate_url("https://").is_err()); + assert!(validate_url("not a url").is_err()); + } + + #[test] + fn is_default_server_normalizes() { + assert!(is_default_server("https://blossom.primal.net")); + assert!(is_default_server("https://blossom.primal.net/")); + assert!(is_default_server("HTTPS://BLOSSOM.PRIMAL.NET")); + assert!(!is_default_server("https://other.example.com")); + } + + #[test] + fn merge_appends_new_urls_normalized() { + let incoming = vec![ + "https://new.example.com/".to_string(), + "https://another.example.com".to_string(), + ]; + let (out, added) = merge_urls_into_customs(&incoming, vec![]); + assert_eq!(added, 2); + assert_eq!(out.len(), 2); + assert_eq!(out[0].url, "https://new.example.com"); + assert!(out[0].enabled); + } + + #[test] + fn merge_skips_urls_already_in_customs_regardless_of_slash_or_case() { + let existing = vec![custom("https://Existing.example.com", true)]; + let incoming = vec![ + "https://existing.example.com/".to_string(), + "HTTPS://EXISTING.EXAMPLE.COM".to_string(), + ]; + let (out, added) = merge_urls_into_customs(&incoming, existing); + assert_eq!(added, 0); + assert_eq!(out.len(), 1); + } + + #[test] + fn merge_skips_default_servers() { + let incoming = vec!["https://blossom.primal.net/".to_string()]; + let (out, added) = merge_urls_into_customs(&incoming, vec![]); + assert_eq!(added, 0); + assert!(out.is_empty()); + } + + #[test] + fn merge_drops_malformed_urls() { + let incoming = vec![ + "".to_string(), + "not a url".to_string(), + "ftp://example.com".to_string(), + "https://".to_string(), + "https://valid.example.com".to_string(), + ]; + let (out, added) = merge_urls_into_customs(&incoming, vec![]); + assert_eq!(added, 1); + assert_eq!(out[0].url, "https://valid.example.com"); + } + + #[test] + fn merge_preserves_existing_order_and_appends() { + let existing = vec![ + custom("https://a.example.com", true), + custom("https://b.example.com", false), + ]; + let incoming = vec!["https://c.example.com".to_string()]; + let (out, _) = merge_urls_into_customs(&incoming, existing); + assert_eq!(out.len(), 3); + assert_eq!(out[0].url, "https://a.example.com"); + assert_eq!(out[1].url, "https://b.example.com"); + assert!(!out[1].enabled, "merge must not flip enabled state of existing rows"); + assert_eq!(out[2].url, "https://c.example.com"); + } +} diff --git a/crates/vector-core/src/chat.rs b/crates/vector-core/src/chat.rs new file mode 100644 index 00000000..e16f0da1 --- /dev/null +++ b/crates/vector-core/src/chat.rs @@ -0,0 +1,845 @@ +//! Chat types and management — compact message storage. +//! +//! `Chat` uses `CompactMessageVec` internally for memory efficiency. +//! Use `to_serializable()` to convert to frontend-friendly `SerializableChat`. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::compact::{CompactMessage, CompactMessageVec, NpubInterner, encode_message_id, decode_message_id}; +use crate::types::Message; + +// ============================================================================ +// Chat (Internal Storage) +// ============================================================================ + +#[derive(Clone, Debug)] +pub struct Chat { + pub id: String, + pub chat_type: ChatType, + pub participants: Vec, + pub messages: CompactMessageVec, + pub last_read: [u8; 32], + pub created_at: u64, + pub metadata: ChatMetadata, + pub muted: bool, + pub typing_participants: Vec<(u16, u64)>, + /// Local cached file path for the per-DM wallpaper, or empty when unset. + /// Populated from the most recent kind-30078 d=vector-wallpaper rumor + /// received for this chat (the encrypted Blossom file is fetched and + /// decrypted to this path). + pub wallpaper_path: String, + /// Rumor created_at (Unix seconds) that produced the current wallpaper. + /// Used as the latest-write-wins tiebreaker on concurrent sets. + pub wallpaper_ts: u64, + /// Blur amount in pixels applied to the wallpaper background layer + /// (0..=30, clamped on read). 0 means no blur. + pub wallpaper_blur: u8, + /// Brightness percent applied to the wallpaper background layer + /// (0..=100, clamped on read). 100 means no darkening; 0 is fully + /// dim. Default 70 keeps text readable on photographic wallpapers. + pub wallpaper_dim: u8, + /// Blossom URL of the encrypted wallpaper blob currently in effect. + /// Used to DELETE the previous blob when we (or our other device) + /// replace the wallpaper, so stale uploads don't linger on servers. + pub wallpaper_url: String, + /// npub (bech32) of whichever account uploaded the current wallpaper. + /// Gate on this before issuing the Blossom DELETE — only the original + /// uploader's signature satisfies the server's auth challenge. + pub wallpaper_uploader: String, +} + +impl Chat { + pub fn new(id: String, chat_type: ChatType, participants: Vec) -> Self { + Self { + id, + chat_type, + participants, + messages: CompactMessageVec::new(), + last_read: [0u8; 32], + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + metadata: ChatMetadata::new(), + muted: false, + typing_participants: Vec::new(), + wallpaper_path: String::new(), + wallpaper_ts: 0, + wallpaper_blur: 0, + wallpaper_dim: 50, + wallpaper_url: String::new(), + wallpaper_uploader: String::new(), + } + } + + pub fn new_dm(their_npub: String, interner: &mut NpubInterner) -> Self { + let handle = interner.intern(&their_npub); + Self::new(their_npub, ChatType::DirectMessage, vec![handle]) + } + + + /// A Community channel chat (GROUP_PROTOCOL.md). `channel_id` is the channel's + /// stable random id (hex); `participants` are the members known so far. + pub fn new_community_channel(channel_id: String, participants: Vec, interner: &mut NpubInterner) -> Self { + let handles: Vec = participants.iter().map(|p| interner.intern(p)).collect(); + Self::new(channel_id, ChatType::Community, handles) + } + + // ======================================================================== + // Message Access + // ======================================================================== + + #[inline] + pub fn message_count(&self) -> usize { self.messages.len() } + + #[inline] + pub fn is_empty(&self) -> bool { self.messages.is_empty() } + + #[inline] + pub fn last_message_time(&self) -> Option { self.messages.last_timestamp() } + + #[inline] + pub fn has_message(&self, id: &str) -> bool { self.messages.contains_hex_id(id) } + + #[inline] + pub fn get_compact_message(&self, id: &str) -> Option<&CompactMessage> { + self.messages.find_by_hex_id(id) + } + + #[inline] + pub fn get_compact_message_mut(&mut self, id: &str) -> Option<&mut CompactMessage> { + self.messages.find_by_hex_id_mut(id) + } + + pub fn get_message(&self, id: &str, interner: &NpubInterner) -> Option { + self.messages.find_by_hex_id(id).map(|cm| cm.to_message(interner)) + } + + #[inline] + pub fn iter_compact(&self) -> std::slice::Iter<'_, CompactMessage> { + self.messages.iter() + } + + pub fn get_all_messages(&self, interner: &NpubInterner) -> Vec { + self.messages.iter().map(|cm| cm.to_message(interner)).collect() + } + + pub fn get_last_messages(&self, n: usize, interner: &NpubInterner) -> Vec { + let len = self.messages.len(); + let start = len.saturating_sub(n); + self.messages.messages()[start..].iter().map(|cm| cm.to_message(interner)).collect() + } + + // ======================================================================== + // Message Mutation + // ======================================================================== + + pub fn add_message(&mut self, message: Message, interner: &mut NpubInterner) -> bool { + let compact = CompactMessage::from_message(&message, interner); + self.messages.insert(compact) + } + + #[inline] + pub fn add_compact_message(&mut self, message: CompactMessage) -> bool { + self.messages.insert(message) + } + + pub fn set_as_read(&mut self) -> bool { + for msg in self.messages.iter().rev() { + if !msg.flags.is_mine() { + self.last_read = msg.id; + return true; + } + } + false + } + + pub fn internal_add_message(&mut self, message: Message, interner: &mut NpubInterner) -> bool { + self.add_message(message, interner) + } + + #[inline] + pub fn get_message_mut(&mut self, id: &str) -> Option<&mut CompactMessage> { + self.get_compact_message_mut(id) + } + + // ======================================================================== + // Serialization + // ======================================================================== + + fn resolve_participants(&self, interner: &NpubInterner) -> Vec { + self.participants.iter() + .filter_map(|&h| interner.resolve(h).map(|s| s.to_string())) + .collect() + } + + pub fn to_serializable(&self, interner: &NpubInterner) -> SerializableChat { + SerializableChat { + id: self.id.clone(), + chat_type: self.chat_type.clone(), + participants: self.resolve_participants(interner), + messages: self.get_all_messages(interner), + last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) }, + created_at: self.created_at, + metadata: self.metadata.clone(), + muted: self.muted, + wallpaper_path: self.wallpaper_path.clone(), + wallpaper_ts: self.wallpaper_ts, + wallpaper_blur: self.wallpaper_blur, + wallpaper_dim: self.wallpaper_dim, + wallpaper_url: self.wallpaper_url.clone(), + wallpaper_uploader: self.wallpaper_uploader.clone(), + } + } + + pub fn to_serializable_with_last_n(&self, n: usize, interner: &NpubInterner) -> SerializableChat { + SerializableChat { + id: self.id.clone(), + chat_type: self.chat_type.clone(), + participants: self.resolve_participants(interner), + messages: self.get_last_messages(n, interner), + last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) }, + created_at: self.created_at, + metadata: self.metadata.clone(), + muted: self.muted, + wallpaper_path: self.wallpaper_path.clone(), + wallpaper_ts: self.wallpaper_ts, + wallpaper_blur: self.wallpaper_blur, + wallpaper_dim: self.wallpaper_dim, + wallpaper_url: self.wallpaper_url.clone(), + wallpaper_uploader: self.wallpaper_uploader.clone(), + } + } + + // ======================================================================== + // Chat Metadata & Participants + // ======================================================================== + + pub fn get_other_participant(&self, my_npub: &str, interner: &NpubInterner) -> Option { + match self.chat_type { + ChatType::DirectMessage => { + let my_handle = interner.lookup(my_npub); + self.participants.iter() + .find(|&&h| Some(h) != my_handle) + .and_then(|&h| interner.resolve(h).map(|s| s.to_string())) + } + // Community channels have no single "other" participant. + ChatType::Community => None, + } + } + + pub fn is_dm_with(&self, npub: &str, interner: &NpubInterner) -> bool { + matches!(self.chat_type, ChatType::DirectMessage) + && interner.lookup(npub).map_or(false, |h| self.participants.contains(&h)) + } + + pub fn is_community(&self) -> bool { matches!(self.chat_type, ChatType::Community) } + + pub fn has_participant(&self, npub: &str, interner: &NpubInterner) -> bool { + interner.lookup(npub).map_or(false, |h| self.participants.contains(&h)) + } + + pub fn get_active_typers(&self, interner: &NpubInterner) -> Vec { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + self.typing_participants.iter() + .filter(|(_, exp)| *exp > now) + .filter_map(|(h, _)| interner.resolve(*h).map(|s| s.to_string())) + .collect() + } + + pub fn update_typing_participant(&mut self, handle: u16, expires_at: u64) { + if let Some(entry) = self.typing_participants.iter_mut().find(|(h, _)| *h == handle) { + entry.1 = expires_at; + } else { + self.typing_participants.push((handle, expires_at)); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + self.typing_participants.retain(|(_, exp)| *exp > now); + } + + pub fn id(&self) -> &String { &self.id } + pub fn chat_type(&self) -> &ChatType { &self.chat_type } + pub fn participants(&self) -> &[u16] { &self.participants } + pub fn last_read(&self) -> &[u8; 32] { &self.last_read } + pub fn created_at(&self) -> u64 { self.created_at } + pub fn metadata(&self) -> &ChatMetadata { &self.metadata } + pub fn muted(&self) -> bool { self.muted } +} + +// ============================================================================ +// SerializableChat (Frontend Communication) +// ============================================================================ + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SerializableChat { + pub id: String, + pub chat_type: ChatType, + pub participants: Vec, + pub messages: Vec, + pub last_read: String, + pub created_at: u64, + pub metadata: ChatMetadata, + pub muted: bool, + #[serde(default)] + pub wallpaper_path: String, + #[serde(default)] + pub wallpaper_ts: u64, + #[serde(default)] + pub wallpaper_blur: u8, + #[serde(default = "default_wallpaper_dim")] + pub wallpaper_dim: u8, + #[serde(default)] + pub wallpaper_url: String, + #[serde(default)] + pub wallpaper_uploader: String, +} + +fn default_wallpaper_dim() -> u8 { 50 } + +impl SerializableChat { + pub fn to_chat(self, interner: &mut NpubInterner) -> Chat { + let handles: Vec = self.participants.iter().map(|p| interner.intern(p)).collect(); + let mut chat = Chat::new(self.id, self.chat_type, handles); + chat.last_read = if self.last_read.is_empty() { [0u8; 32] } else { encode_message_id(&self.last_read) }; + chat.created_at = self.created_at; + chat.metadata = self.metadata; + chat.muted = self.muted; + chat.wallpaper_path = self.wallpaper_path; + chat.wallpaper_ts = self.wallpaper_ts; + chat.wallpaper_blur = self.wallpaper_blur; + chat.wallpaper_dim = self.wallpaper_dim; + chat.wallpaper_url = self.wallpaper_url; + chat.wallpaper_uploader = self.wallpaper_uploader; + for msg in self.messages { + chat.add_message(msg, interner); + } + chat + } +} + +// ============================================================================ +// Supporting Types +// ============================================================================ + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum ChatType { + DirectMessage, + /// A Community channel (GROUP_PROTOCOL.md). The chat `id` + /// is the channel's stable random id. + Community, +} + +impl ChatType { + // Discriminant 1 was the removed MlsGroup variant; Community keeps 2 for + // on-disk compatibility. Legacy chat_type=1 rows are dropped at the DB load layer. + pub fn to_i32(&self) -> i32 { + match self { + ChatType::DirectMessage => 0, + ChatType::Community => 2, + } + } + pub fn from_i32(value: i32) -> Self { + match value { + 2 => ChatType::Community, + _ => ChatType::DirectMessage, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] +pub struct ChatMetadata { + pub custom_fields: HashMap, +} + +impl ChatMetadata { + pub fn new() -> Self { Self { custom_fields: HashMap::new() } } + + pub fn set_name(&mut self, name: String) { self.custom_fields.insert("name".to_string(), name); } + pub fn get_name(&self) -> Option<&str> { self.custom_fields.get("name").map(|s| s.as_str()) } + pub fn set_member_count(&mut self, count: usize) { self.custom_fields.insert("member_count".to_string(), count.to_string()); } + pub fn get_member_count(&self) -> Option { self.custom_fields.get("member_count").and_then(|s| s.parse().ok()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Message; + use crate::compact::NpubInterner; + use crate::simd::hex::bytes_to_hex_32; + + // ======================================================================== + // Helpers + // ======================================================================== + + /// Create a deterministic 64-char hex ID from a u8 seed. + /// First byte is always >= 0x10 to avoid the pending ID marker (0x01). + fn make_hex_id(seed: u8) -> String { + let mut bytes = [seed; 32]; + bytes[0] = seed.wrapping_add(0x10) | 0x10; // never 0x00 or 0x01 + bytes[1] = seed.wrapping_mul(37); + bytes_to_hex_32(&bytes) + } + + /// Build a test Message with the given parameters. + fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message { + Message { + id: make_hex_id(id_seed), + content: content.to_string(), + at: timestamp_ms, + mine, + ..Default::default() + } + } + + // ======================================================================== + // Chat Construction + // ======================================================================== + + #[test] + fn new_dm_creates_correct_type() { + let mut interner = NpubInterner::new(); + let chat = Chat::new_dm("npub1alice".to_string(), &mut interner); + + assert_eq!(chat.id, "npub1alice", "DM chat id should be the peer's npub"); + assert_eq!(chat.chat_type, ChatType::DirectMessage, "should be DirectMessage type"); + assert_eq!(chat.participants.len(), 1, "DM should have one participant"); + assert!(chat.is_empty(), "new chat should have no messages"); + assert!(!chat.muted, "new chat should not be muted"); + assert_eq!(chat.last_read, [0u8; 32], "last_read should be zeroed"); + } + + #[test] + fn new_community_channel_with_participants() { + let mut interner = NpubInterner::new(); + let participants = vec![ + "npub1alice".to_string(), + "npub1bob".to_string(), + "npub1charlie".to_string(), + ]; + let chat = Chat::new_community_channel("grp_abc".to_string(), participants, &mut interner); + + assert_eq!(chat.id, "grp_abc", "group chat id should match"); + assert_eq!(chat.chat_type, ChatType::Community, "should be Community type"); + assert_eq!(chat.participants.len(), 3, "should have 3 participants"); + assert!(chat.is_community(), "is_community() should return true"); + } + + #[test] + fn new_chat_has_creation_timestamp() { + let mut interner = NpubInterner::new(); + let chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + // created_at should be recent (within last 5 seconds) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + assert!( + chat.created_at >= now - 5 && chat.created_at <= now + 1, + "created_at ({}) should be close to now ({})", + chat.created_at, now + ); + } + + // ======================================================================== + // Message Operations + // ======================================================================== + + #[test] + fn add_message_and_get_message_roundtrip() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + let msg = make_message(1, "hello world", 1700000000000, false); + let msg_id = msg.id.clone(); + + let added = chat.add_message(msg, &mut interner); + assert!(added, "message should be added successfully"); + + let retrieved = chat.get_message(&msg_id, &interner) + .expect("message should be retrievable"); + assert_eq!(retrieved.content, "hello world", "content should roundtrip"); + assert_eq!(retrieved.id, msg_id, "id should roundtrip"); + } + + #[test] + fn add_message_dedup() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + let msg1 = make_message(1, "first", 1700000000000, false); + let msg2 = make_message(1, "duplicate id", 1700000001000, false); + + assert!(chat.add_message(msg1, &mut interner), "first add should succeed"); + assert!(!chat.add_message(msg2, &mut interner), "duplicate ID should be rejected"); + assert_eq!(chat.message_count(), 1, "should have only one message"); + } + + #[test] + fn message_count_and_is_empty() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + assert!(chat.is_empty(), "new chat should be empty"); + assert_eq!(chat.message_count(), 0, "new chat should have 0 messages"); + + chat.add_message(make_message(1, "a", 1700000000000, false), &mut interner); + assert!(!chat.is_empty(), "chat with message should not be empty"); + assert_eq!(chat.message_count(), 1, "should have 1 message"); + + chat.add_message(make_message(2, "b", 1700000001000, false), &mut interner); + assert_eq!(chat.message_count(), 2, "should have 2 messages"); + } + + #[test] + fn has_message_check() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + let msg = make_message(1, "test", 1700000000000, false); + let msg_id = msg.id.clone(); + chat.add_message(msg, &mut interner); + + assert!(chat.has_message(&msg_id), "added message should be found"); + assert!(!chat.has_message(&make_hex_id(99)), "unknown id should not be found"); + } + + #[test] + fn get_all_messages_returns_all() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + for i in 0..5u8 { + chat.add_message( + make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false), + &mut interner, + ); + } + + let all = chat.get_all_messages(&interner); + assert_eq!(all.len(), 5, "should return all 5 messages"); + } + + #[test] + fn get_last_messages_returns_tail() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + for i in 0..10u8 { + chat.add_message( + make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false), + &mut interner, + ); + } + + let last3 = chat.get_last_messages(3, &interner); + assert_eq!(last3.len(), 3, "should return exactly 3 messages"); + assert_eq!(last3[0].content, "msg 7", "first of last 3 should be msg 7"); + assert_eq!(last3[2].content, "msg 9", "last should be msg 9"); + } + + #[test] + fn last_message_time_tracks_newest() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + assert!(chat.last_message_time().is_none(), "empty chat should have no last time"); + + chat.add_message(make_message(1, "a", 1700000001000, false), &mut interner); + let t1 = chat.last_message_time().expect("should have a timestamp"); + + chat.add_message(make_message(2, "b", 1700000005000, false), &mut interner); + let t2 = chat.last_message_time().expect("should have a timestamp"); + + assert!(t2 > t1, "last_message_time should increase with newer messages"); + } + + // ======================================================================== + // set_as_read + // ======================================================================== + + #[test] + fn set_as_read_marks_last_non_mine() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + let msg_theirs = make_message(1, "from them", 1700000001000, false); + let msg_mine = make_message(2, "from me", 1700000002000, true); + let msg_theirs2 = make_message(3, "from them again", 1700000003000, false); + let last_their_id = msg_theirs2.id.clone(); + + chat.add_message(msg_theirs, &mut interner); + chat.add_message(msg_mine, &mut interner); + chat.add_message(msg_theirs2, &mut interner); + + let marked = chat.set_as_read(); + assert!(marked, "set_as_read should succeed when there are non-mine messages"); + + let expected_bytes = crate::compact::encode_message_id(&last_their_id); + assert_eq!( + chat.last_read, expected_bytes, + "last_read should point to the last non-mine message" + ); + } + + #[test] + fn set_as_read_all_mine_returns_false() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + chat.add_message(make_message(1, "mine 1", 1700000001000, true), &mut interner); + chat.add_message(make_message(2, "mine 2", 1700000002000, true), &mut interner); + + let marked = chat.set_as_read(); + assert!(!marked, "set_as_read should return false when all messages are mine"); + assert_eq!(chat.last_read, [0u8; 32], "last_read should remain zeroed"); + } + + // ======================================================================== + // Serialization Roundtrip + // ======================================================================== + + #[test] + fn to_serializable_and_back_via_to_chat() { + let mut interner = NpubInterner::new(); + let participants = vec!["npub1alice".to_string(), "npub1bob".to_string()]; + let mut chat = Chat::new_community_channel("grp_test".to_string(), participants.clone(), &mut interner); + + chat.metadata.set_name("Test Group".to_string()); + chat.muted = true; + + // Add messages + chat.add_message(make_message(1, "hello", 1700000001000, false), &mut interner); + chat.add_message(make_message(2, "world", 1700000002000, true), &mut interner); + + // Set last_read + chat.set_as_read(); + + // Serialize to SerializableChat + let serializable = chat.to_serializable(&interner); + assert_eq!(serializable.id, "grp_test", "serialized id should match"); + assert_eq!(serializable.chat_type, ChatType::Community, "serialized type should match"); + assert_eq!(serializable.participants.len(), 2, "should have 2 participants"); + assert_eq!(serializable.messages.len(), 2, "should have 2 messages"); + assert!(serializable.muted, "muted should be preserved"); + assert_eq!( + serializable.metadata.get_name(), + Some("Test Group"), + "metadata name should be preserved" + ); + + // Convert back to Chat + let mut interner2 = NpubInterner::new(); + let restored = serializable.to_chat(&mut interner2); + + assert_eq!(restored.id, "grp_test", "restored id should match"); + assert_eq!(restored.chat_type, ChatType::Community, "restored type should match"); + assert_eq!(restored.participants.len(), 2, "restored participants count should match"); + assert_eq!(restored.message_count(), 2, "restored message count should match"); + assert!(restored.muted, "restored muted should be true"); + assert_ne!(restored.last_read, [0u8; 32], "restored last_read should be non-zero"); + } + + #[test] + fn to_serializable_with_last_n() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + for i in 0..10u8 { + chat.add_message( + make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false), + &mut interner, + ); + } + + let serialized = chat.to_serializable_with_last_n(3, &interner); + assert_eq!(serialized.messages.len(), 3, "should only include last 3 messages"); + } + + // ======================================================================== + // Participants + // ======================================================================== + + #[test] + fn has_participant_check() { + let mut interner = NpubInterner::new(); + let chat = Chat::new_community_channel( + "grp1".to_string(), + vec!["npub1alice".to_string(), "npub1bob".to_string()], + &mut interner, + ); + + assert!( + chat.has_participant("npub1alice", &interner), + "alice should be a participant" + ); + assert!( + chat.has_participant("npub1bob", &interner), + "bob should be a participant" + ); + assert!( + !chat.has_participant("npub1charlie", &interner), + "charlie should not be a participant" + ); + } + + #[test] + fn is_dm_with_check() { + let mut interner = NpubInterner::new(); + let chat = Chat::new_dm("npub1alice".to_string(), &mut interner); + + assert!( + chat.is_dm_with("npub1alice", &interner), + "should be a DM with alice" + ); + assert!( + !chat.is_dm_with("npub1bob", &interner), + "should not be a DM with bob" + ); + } + + #[test] + fn is_dm_with_returns_false_for_group() { + let mut interner = NpubInterner::new(); + let chat = Chat::new_community_channel( + "grp1".to_string(), + vec!["npub1alice".to_string()], + &mut interner, + ); + + assert!( + !chat.is_dm_with("npub1alice", &interner), + "community channel should not match is_dm_with even if participant matches" + ); + } + + #[test] + fn get_other_participant_dm() { + let mut interner = NpubInterner::new(); + // In a DM, the participant list typically has the other person + let chat = Chat::new_dm("npub1bob".to_string(), &mut interner); + + let other = chat.get_other_participant("npub1alice", &interner); + assert_eq!( + other, Some("npub1bob".to_string()), + "should return bob as the other participant" + ); + } + + #[test] + fn get_other_participant_returns_none_for_group() { + let mut interner = NpubInterner::new(); + let chat = Chat::new_community_channel( + "grp1".to_string(), + vec!["npub1alice".to_string(), "npub1bob".to_string()], + &mut interner, + ); + + assert!( + chat.get_other_participant("npub1alice", &interner).is_none(), + "community channel should return None for get_other_participant" + ); + } + + // ======================================================================== + // Typing Participants + // ======================================================================== + + #[test] + fn typing_participants_with_expiry() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + + // One active, one expired + let active_handle = interner.intern("npub1active"); + let expired_handle = interner.intern("npub1expired"); + + chat.update_typing_participant(active_handle, now + 300); + chat.update_typing_participant(expired_handle, now - 10); + + let active = chat.get_active_typers(&interner); + assert_eq!(active.len(), 1, "only the active typer should be returned"); + assert_eq!(active[0], "npub1active", "active typer should be npub1active"); + } + + #[test] + fn update_typing_participant_refreshes() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + + let handle = interner.intern("npub1typer"); + chat.update_typing_participant(handle, now + 100); + chat.update_typing_participant(handle, now + 500); + + // Should still be one entry, not duplicated + let active = chat.get_active_typers(&interner); + assert_eq!(active.len(), 1, "refreshed typer should not duplicate"); + } + + #[test] + fn typing_participants_empty_initially() { + let interner = NpubInterner::new(); + let chat = Chat::new("testchat".to_string(), ChatType::DirectMessage, vec![]); + + let active = chat.get_active_typers(&interner); + assert!(active.is_empty(), "new chat should have no typers"); + } + + // ======================================================================== + // ChatType + // ======================================================================== + + #[test] + fn chat_type_i32_roundtrip() { + assert_eq!(ChatType::from_i32(ChatType::DirectMessage.to_i32()), ChatType::DirectMessage); + assert_eq!(ChatType::from_i32(ChatType::Community.to_i32()), ChatType::Community); + assert_eq!( + ChatType::from_i32(999), ChatType::DirectMessage, + "unknown i32 should default to DirectMessage" + ); + } + + // ======================================================================== + // ChatMetadata + // ======================================================================== + + #[test] + fn chat_metadata_name_and_member_count() { + let mut meta = ChatMetadata::new(); + + assert!(meta.get_name().is_none(), "new metadata should have no name"); + assert!(meta.get_member_count().is_none(), "new metadata should have no member count"); + + meta.set_name("My Group".to_string()); + meta.set_member_count(42); + + assert_eq!(meta.get_name(), Some("My Group"), "name should be set"); + assert_eq!(meta.get_member_count(), Some(42), "member count should be set"); + } + + // ======================================================================== + // Accessor Methods + // ======================================================================== + + #[test] + fn accessor_methods_work() { + let mut interner = NpubInterner::new(); + let mut chat = Chat::new_dm("npub1test".to_string(), &mut interner); + chat.muted = true; + + assert_eq!(chat.id(), "npub1test"); + assert_eq!(*chat.chat_type(), ChatType::DirectMessage); + assert_eq!(chat.participants().len(), 1); + assert_eq!(*chat.last_read(), [0u8; 32]); + assert!(chat.created_at() > 0); + assert!(chat.muted()); + assert_eq!(*chat.metadata(), ChatMetadata::new()); + } +} diff --git a/crates/vector-core/src/compact.rs b/crates/vector-core/src/compact.rs new file mode 100644 index 00000000..bf5b4e92 --- /dev/null +++ b/crates/vector-core/src/compact.rs @@ -0,0 +1,3694 @@ +//! Compact message storage with binary IDs and interned strings. +//! +//! Part of vector-core — the single source of truth for compact message types. +//! +//! This module provides memory-efficient message storage: +//! - `[u8; 32]` for IDs instead of hex strings (saves ~56 bytes per ID) +//! - Interned npubs via `NpubInterner` (each unique npub stored once) +//! - Bitflags for boolean states (1 byte instead of 4+) +//! - Binary search for O(log n) message lookup +//! - Boxed optional fields (replied_to, wrapper_id) to save inline space +//! - Compact timestamp (u32 seconds since 2020 epoch) + +use crate::types::{Attachment, EditEntry, ImageMetadata, Reaction, SiteMetadata}; +use crate::simd::hex::{bytes_to_hex_32, bytes_to_hex_string, hex_to_bytes_32}; + +/// Decode a hex string of up to 32 hex chars into [u8; 16], left-aligned. +/// Pads short inputs with '0' on the right before decoding. +fn hex_to_bytes_16(hex: &str) -> [u8; 16] { + let mut out = [0u8; 16]; + let h = hex.as_bytes(); + // Pad to 32 hex chars with '0' + let mut padded = [b'0'; 32]; + let copy_len = h.len().min(32); + padded[..copy_len].copy_from_slice(&h[..copy_len]); + for i in 0..16 { + let hi = hex_nibble(padded[i * 2]); + let lo = hex_nibble(padded[i * 2 + 1]); + out[i] = (hi << 4) | lo; + } + out +} + +#[inline] +fn hex_nibble(b: u8) -> u8 { + match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + b'A'..=b'F' => b - b'A' + 10, + _ => 0, + } +} + +// ============================================================================ +// Pending ID Encoding +// ============================================================================ + +/// Marker byte for pending IDs (first byte = 0x01) +/// Real event IDs are random SHA256 hashes, so this is safe. +const PENDING_ID_MARKER: u8 = 0x01; + +/// Encode an ID string to 32 bytes, handling pending IDs specially. +/// - Pending IDs ("pending-{nanoseconds}") are encoded with marker byte + timestamp +/// - Regular hex IDs are decoded normally +#[inline] +pub fn encode_message_id(id: &str) -> [u8; 32] { + if let Some(timestamp_str) = id.strip_prefix("pending-") { + // Encode pending ID: marker byte + timestamp as u128 (16 bytes) + let mut bytes = [0u8; 32]; + bytes[0] = PENDING_ID_MARKER; + if let Ok(timestamp) = timestamp_str.parse::() { + bytes[1..17].copy_from_slice(×tamp.to_le_bytes()); + } + bytes + } else { + hex_to_bytes_32(id) + } +} + +/// Decode 32 bytes back to an ID string, handling pending IDs specially. +#[inline] +pub fn decode_message_id(bytes: &[u8; 32]) -> String { + if bytes[0] == PENDING_ID_MARKER { + // Decode pending ID: extract timestamp from bytes 1-16 + let mut timestamp_bytes = [0u8; 16]; + timestamp_bytes.copy_from_slice(&bytes[1..17]); + let timestamp = u128::from_le_bytes(timestamp_bytes); + format!("pending-{}", timestamp) + } else { + bytes_to_hex_32(bytes) + } +} + +// ============================================================================ +// Compact Timestamp +// ============================================================================ + +/// Convert milliseconds timestamp to compact storage. +/// Stores full u64 milliseconds — no precision loss. +#[inline] +pub fn timestamp_to_compact(ms: u64) -> u64 { + ms +} + +/// Convert compact timestamp back to milliseconds. +#[inline] +pub fn timestamp_from_compact(compact: u64) -> u64 { + compact +} + +/// Custom epoch in seconds: 2020-01-01 00:00:00 UTC +const EPOCH_2020_SECS: u64 = 1577836800; + +/// Convert Unix seconds timestamp to compact u32 (seconds since 2020). +/// Preserves 0 as sentinel for "never set". +#[inline] +pub fn secs_to_compact(secs: u64) -> u32 { + if secs == 0 { return 0; } + secs.saturating_sub(EPOCH_2020_SECS) as u32 +} + +/// Convert compact u32 back to Unix seconds timestamp. +/// Preserves 0 as sentinel for "never set". +#[inline] +pub fn secs_from_compact(compact: u32) -> u64 { + if compact == 0 { return 0; } + EPOCH_2020_SECS + compact as u64 +} + +// ============================================================================ +// Message Flags +// ============================================================================ + +/// Bitflags for message state (1 byte instead of 4+ bytes for separate bools) +/// +/// Layout (bits): 0=mine, 1=pending, 2=failed, 3-4=replied_to_has_attachment +/// replied_to_has_attachment: 00=None, 01=Some(false), 10=Some(true) +/// +/// Note: No EDITED flag - check `edit_history.is_some()` instead +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MessageFlags(u8); + +impl MessageFlags { + pub const NONE: Self = Self(0); + pub const MINE: Self = Self(0b00001); + pub const PENDING: Self = Self(0b00010); + pub const FAILED: Self = Self(0b00100); + // Bits 3-4 for replied_to_has_attachment: + // 00 = None, 01 = Some(false), 10 = Some(true) + const REPLY_ATTACH_MASK: u8 = 0b11000; + const REPLY_ATTACH_SHIFT: u8 = 3; + + #[inline] + pub fn is_mine(self) -> bool { + self.0 & Self::MINE.0 != 0 + } + + #[inline] + pub fn is_pending(self) -> bool { + self.0 & Self::PENDING.0 != 0 + } + + #[inline] + pub fn is_failed(self) -> bool { + self.0 & Self::FAILED.0 != 0 + } + + /// Get replied_to_has_attachment as Option + /// Returns None (unknown), Some(false), or Some(true) + #[inline] + pub fn replied_to_has_attachment(self) -> Option { + match (self.0 & Self::REPLY_ATTACH_MASK) >> Self::REPLY_ATTACH_SHIFT { + 0b00 => None, // Unknown + 0b01 => Some(false), // No attachment + 0b10 => Some(true), // Has attachment + _ => None, // Invalid, treat as unknown + } + } + + #[inline] + pub fn set_mine(&mut self, value: bool) { + if value { + self.0 |= Self::MINE.0; + } else { + self.0 &= !Self::MINE.0; + } + } + + #[inline] + pub fn set_pending(&mut self, value: bool) { + if value { + self.0 |= Self::PENDING.0; + } else { + self.0 &= !Self::PENDING.0; + } + } + + #[inline] + pub fn set_failed(&mut self, value: bool) { + if value { + self.0 |= Self::FAILED.0; + } else { + self.0 &= !Self::FAILED.0; + } + } + + /// Set replied_to_has_attachment from Option + #[inline] + pub fn set_replied_to_has_attachment(&mut self, value: Option) { + // Clear existing bits + self.0 &= !Self::REPLY_ATTACH_MASK; + // Set new value + let bits = match value { + None => 0b00, + Some(false) => 0b01, + Some(true) => 0b10, + }; + self.0 |= bits << Self::REPLY_ATTACH_SHIFT; + } + + /// Create flags from individual booleans + #[inline] + pub fn from_bools(mine: bool, pending: bool, failed: bool) -> Self { + let mut flags = Self::NONE; + flags.set_mine(mine); + flags.set_pending(pending); + flags.set_failed(failed); + flags + } + + /// Create flags from all values including replied_to_has_attachment + #[inline] + pub fn from_all(mine: bool, pending: bool, failed: bool, replied_to_has_attachment: Option) -> Self { + let mut flags = Self::from_bools(mine, pending, failed); + flags.set_replied_to_has_attachment(replied_to_has_attachment); + flags + } +} + +// ============================================================================ +// TinyVec - 8-byte thin pointer for small collections +// ============================================================================ + +use std::alloc::{alloc, dealloc, Layout}; +use std::marker::PhantomData; +use std::ptr::NonNull; + +/// Ultra-compact vector using a thin pointer (8 bytes on stack). +/// +/// Memory layout: +/// - Stack: single pointer (8 bytes) - null for empty +/// - Heap: `[len: u8][items: T...]` - only allocated when non-empty +/// +/// Compared to standard types: +/// - `Vec`: 24 bytes (ptr + len + cap) +/// - `Box<[T]>`: 16 bytes (fat pointer) +/// - `TinyVec`: 8 bytes (thin pointer) +/// +/// Limitations: +/// - Max 255 items (u8 length) +/// - Immutable after creation (no push/pop - recreate to modify) +/// - Perfect for attachments/reactions which rarely change +pub struct TinyVec { + /// Null = empty, otherwise points to: [len: u8][items: T...] + ptr: Option>, + _marker: PhantomData, +} + +impl TinyVec { + /// Create an empty TinyVec (no allocation) + #[inline] + pub const fn new() -> Self { + Self { + ptr: None, + _marker: PhantomData, + } + } + + /// Create from a Vec, consuming it + pub fn from_vec(vec: Vec) -> Self { + if vec.is_empty() { + return Self::new(); + } + + let len = vec.len().min(255) as u8; + + // Calculate layout: 1 byte for length + items + let (layout, items_offset) = Self::layout_for(len as usize); + + unsafe { + // Allocate + let ptr = alloc(layout); + if ptr.is_null() { + std::alloc::handle_alloc_error(layout); + } + + // Write length + *ptr = len; + + // Move items (no clone!) + let items_ptr = ptr.add(items_offset) as *mut T; + for (i, item) in vec.into_iter().take(len as usize).enumerate() { + std::ptr::write(items_ptr.add(i), item); + } + + Self { + ptr: NonNull::new(ptr), + _marker: PhantomData, + } + } + } + + /// Calculate layout for allocation + fn layout_for(len: usize) -> (Layout, usize) { + let header_layout = Layout::new::(); + let items_layout = Layout::array::(len).unwrap(); + header_layout.extend(items_layout).unwrap() + } + + /// Number of items + #[inline] + pub fn len(&self) -> usize { + match self.ptr { + None => 0, + Some(ptr) => unsafe { *ptr.as_ptr() as usize }, + } + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.ptr.is_none() + } + + /// Get items offset within allocation + #[inline] + fn items_offset() -> usize { + let header_layout = Layout::new::(); + let items_layout = Layout::new::(); + header_layout.extend(items_layout).map(|(_, offset)| offset).unwrap_or(1) + } + + /// Get a slice of the items + #[inline] + pub fn as_slice(&self) -> &[T] { + match self.ptr { + None => &[], + Some(ptr) => unsafe { + let base = ptr.as_ptr(); + let len = *base as usize; + let items_ptr = base.add(Self::items_offset()) as *const T; + std::slice::from_raw_parts(items_ptr, len) + }, + } + } + + /// Get a mutable slice of the items + #[inline] + pub fn as_mut_slice(&mut self) -> &mut [T] { + match self.ptr { + None => &mut [], + Some(ptr) => unsafe { + let base = ptr.as_ptr(); + let len = *base as usize; + let items_ptr = base.add(Self::items_offset()) as *mut T; + std::slice::from_raw_parts_mut(items_ptr, len) + }, + } + } + + /// Iterate over items + #[inline] + pub fn iter(&self) -> std::slice::Iter<'_, T> { + self.as_slice().iter() + } + + /// Iterate mutably + #[inline] + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, T> { + self.as_mut_slice().iter_mut() + } + + /// Convert to Vec (clones items) + pub fn to_vec(&self) -> Vec + where + T: Clone, + { + self.as_slice().to_vec() + } + + /// Get the first item (immutable) + #[inline] + pub fn first(&self) -> Option<&T> { + self.as_slice().first() + } + + /// Get the last item (immutable) + #[inline] + pub fn last(&self) -> Option<&T> { + self.as_slice().last() + } + + /// Get the last item (mutable) + #[inline] + pub fn last_mut(&mut self) -> Option<&mut T> { + self.as_mut_slice().last_mut() + } + + /// Get item by index (immutable) + #[inline] + pub fn get(&self, index: usize) -> Option<&T> { + self.as_slice().get(index) + } + + /// Get item by index (mutable) + #[inline] + pub fn get_mut(&mut self, index: usize) -> Option<&mut T> { + self.as_mut_slice().get_mut(index) + } + + /// Push an item (rebuilds the entire allocation - use sparingly!) + pub fn push(&mut self, item: T) + where + T: Clone, + { + let mut vec = self.to_vec(); + vec.push(item); + *self = Self::from_vec(vec); + } + + /// Retain items matching a predicate (rebuilds the allocation) + pub fn retain(&mut self, f: F) + where + T: Clone, + F: FnMut(&T) -> bool, + { + let mut vec = self.to_vec(); + vec.retain(f); + *self = Self::from_vec(vec); + } + + /// Check if any item matches a predicate + pub fn any(&self, f: F) -> bool + where + F: FnMut(&T) -> bool, + { + self.as_slice().iter().any(f) + } +} + +// Index trait for direct indexing (msg.attachments[0]) +impl std::ops::Index for TinyVec { + type Output = T; + + fn index(&self, index: usize) -> &Self::Output { + &self.as_slice()[index] + } +} + +impl std::ops::IndexMut for TinyVec { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.as_mut_slice()[index] + } +} + +// IntoIterator for &TinyVec +impl<'a, T> IntoIterator for &'a TinyVec { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.as_slice().iter() + } +} + +// IntoIterator for &mut TinyVec +impl<'a, T> IntoIterator for &'a mut TinyVec { + type Item = &'a mut T; + type IntoIter = std::slice::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.as_mut_slice().iter_mut() + } +} + +impl Default for TinyVec { + fn default() -> Self { + Self::new() + } +} + +impl Clone for TinyVec { + fn clone(&self) -> Self { + Self::from_vec(self.to_vec()) + } +} + +impl std::fmt::Debug for TinyVec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list().entries(self.as_slice()).finish() + } +} + +impl Drop for TinyVec { + fn drop(&mut self) { + if let Some(ptr) = self.ptr { + unsafe { + let base = ptr.as_ptr(); + let len = *base as usize; + let items_ptr = base.add(Self::items_offset()) as *mut T; + + // Drop each item + for i in 0..len { + std::ptr::drop_in_place(items_ptr.add(i)); + } + + // Deallocate + let (layout, _) = Self::layout_for(len); + dealloc(base, layout); + } + } + } +} + +// Safety: TinyVec is Send/Sync if T is +unsafe impl Send for TinyVec {} +unsafe impl Sync for TinyVec {} + +// ============================================================================ +// Compact Reaction +// ============================================================================ + +/// Memory-efficient reaction with binary IDs and interned author. +/// +/// Compared to the regular `Reaction` struct (~292 bytes with heap): +/// - IDs use `[u8; 32]` instead of hex String (saves ~56 bytes each) +/// - Author uses u16 index into interner (saves ~86 bytes) +/// - Emoji uses Box (saves 8 bytes, supports custom emoji like `:cat_heart_eyes:`) +/// - Total: ~82 bytes vs ~292 bytes (72% savings!) +#[derive(Clone, Debug)] +pub struct CompactReaction { + /// Reaction event ID as binary + pub id: [u8; 32], + /// Message being reacted to (binary event ID) + pub reference_id: [u8; 32], + /// Author npub index (interned via NpubInterner) + pub author_idx: u16, + /// Emoji string (supports standard emoji and custom like `:cat_heart_eyes:`) + pub emoji: Box, + /// NIP-30 custom-emoji URL when the reaction is `:shortcode:` form. + /// Boxed so the cold path (stock unicode reactions) stays 8 bytes. + pub emoji_url: Option>, +} + +impl CompactReaction { + /// Get reaction ID as hex string + #[inline] + pub fn id_hex(&self) -> String { + bytes_to_hex_32(&self.id) + } + + /// Get reference ID as hex string + #[inline] + pub fn reference_id_hex(&self) -> String { + bytes_to_hex_32(&self.reference_id) + } + + /// Convert from regular Reaction, interning author + pub fn from_reaction(reaction: &Reaction, interner: &mut NpubInterner) -> Self { + Self { + id: hex_to_bytes_32(&reaction.id), + reference_id: hex_to_bytes_32(&reaction.reference_id), + author_idx: interner.intern(&reaction.author_id), + emoji: reaction.emoji.clone().into_boxed_str(), + emoji_url: reaction.emoji_url.as_deref().map(|s| s.into()), + } + } + + /// Convert from regular Reaction (owned), interning author + pub fn from_reaction_owned(reaction: Reaction, interner: &mut NpubInterner) -> Self { + Self { + id: hex_to_bytes_32(&reaction.id), + reference_id: hex_to_bytes_32(&reaction.reference_id), + author_idx: interner.intern(&reaction.author_id), + emoji: reaction.emoji.into_boxed_str(), + emoji_url: reaction.emoji_url.map(|s| s.into_boxed_str()), + } + } + + /// Convert back to regular Reaction, resolving author from interner + pub fn to_reaction(&self, interner: &NpubInterner) -> Reaction { + Reaction { + id: self.id_hex(), + reference_id: self.reference_id_hex(), + author_id: interner.resolve(self.author_idx) + .map(|s| s.to_string()) + .unwrap_or_default(), + emoji: self.emoji.to_string(), + emoji_url: self.emoji_url.as_deref().map(|s| s.to_string()), + } + } +} + +// ============================================================================ +// Compact Attachment +// ============================================================================ + +/// Packed flags for attachment state (1 byte instead of multiple bools) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct AttachmentFlags(u8); + +impl AttachmentFlags { + pub const NONE: Self = Self(0); + const DOWNLOADING: u8 = 0b0001; + const DOWNLOADED: u8 = 0b0010; + const SHORT_NONCE: u8 = 0b0100; // 12-byte nonce (legacy) vs 16-byte (DM) + + #[inline] + pub fn is_downloading(self) -> bool { self.0 & Self::DOWNLOADING != 0 } + #[inline] + pub fn is_downloaded(self) -> bool { self.0 & Self::DOWNLOADED != 0 } + #[inline] + pub fn is_short_nonce(self) -> bool { self.0 & Self::SHORT_NONCE != 0 } + + #[inline] + pub fn set_downloading(&mut self, value: bool) { + if value { self.0 |= Self::DOWNLOADING; } else { self.0 &= !Self::DOWNLOADING; } + } + #[inline] + pub fn set_downloaded(&mut self, value: bool) { + if value { self.0 |= Self::DOWNLOADED; } else { self.0 &= !Self::DOWNLOADED; } + } + #[inline] + pub fn set_short_nonce(&mut self, value: bool) { + if value { self.0 |= Self::SHORT_NONCE; } else { self.0 &= !Self::SHORT_NONCE; } + } + + pub fn from_bools(downloading: bool, downloaded: bool) -> Self { + let mut flags = Self::NONE; + flags.set_downloading(downloading); + flags.set_downloaded(downloaded); + flags + } +} + +/// Memory-efficient attachment with binary hashes and compact strings. +/// +/// Compared to the regular `Attachment` struct (~320+ bytes): +/// - id (SHA256): `[u8; 32]` instead of hex String (saves ~56 bytes) +/// - key: `[u8; 32]` instead of String (saves ~56 bytes) +/// - nonce: `[u8; 16]` instead of String (saves ~32 bytes) +/// - Bools packed into AttachmentFlags (saves padding) +/// - Strings use Box (saves 8 bytes each) +/// - Rare fields boxed (saves ~100+ bytes when None) +/// - Total: ~120 bytes vs ~320 bytes (62% savings!) +#[derive(Clone, Debug)] +pub struct CompactAttachment { + // === Fixed binary fields === + /// SHA256 file hash as binary (was hex String) + pub id: [u8; 32], + /// Encryption key - 32 bytes (empty = legacy derived) + pub key: [u8; 32], + /// Encryption nonce - 16 bytes (AES-256-GCM with 0xChat compatibility) + pub nonce: [u8; 16], + /// File size in bytes + pub size: u64, + /// Packed boolean flags (downloading, downloaded) + pub flags: AttachmentFlags, + + // === Variable fields (Box = 16 bytes each vs String's 24) === + /// File extension (e.g., "png", "mp4") + pub extension: Box, + /// Host URL (blossom server, etc.) + pub url: Box, + /// Local file path (empty if not downloaded) + pub path: Box, + + // === Optional fields - boxed to save space when None === + /// Image metadata (only for images/videos) + pub img_meta: Option>, + /// Legacy group ID for key derivation + pub group_id: Option>, + /// Original file hash before encryption + pub original_hash: Option>, + /// WebXDC topic (Mini Apps only - very rare) + pub webxdc_topic: Option>, + /// Legacy filename for AAD + pub mls_filename: Option>, + /// Scheme version (e.g., "mip04-v1") + pub scheme_version: Option>, + /// Original filename (e.g. "memories.zip"). Empty = fallback to {hash}.{ext} + pub name: String, +} + +impl CompactAttachment { + // === Convenience accessors for flags === + #[inline] + pub fn downloaded(&self) -> bool { self.flags.is_downloaded() } + #[inline] + pub fn downloading(&self) -> bool { self.flags.is_downloading() } + #[inline] + pub fn set_downloaded(&mut self, value: bool) { self.flags.set_downloaded(value); } + #[inline] + pub fn set_downloading(&mut self, value: bool) { self.flags.set_downloading(value); } + + /// Check if this attachment's ID matches a hex string + #[inline] + pub fn id_eq(&self, hex_id: &str) -> bool { + self.id == hex_to_bytes_32(hex_id) + } + + /// Get file ID as hex string + #[inline] + pub fn id_hex(&self) -> String { + bytes_to_hex_32(&self.id) + } + + /// Get encryption key as hex string (empty if zeros) + pub fn key_hex(&self) -> String { + if self.key == [0u8; 32] { + String::new() + } else { + bytes_to_hex_32(&self.key) + } + } + + /// Get nonce as hex string (empty if zeros, respects original length) + pub fn nonce_hex(&self) -> String { + if self.nonce == [0u8; 16] { + String::new() + } else if self.flags.is_short_nonce() { + // 12-byte nonce (legacy/MIP-04) + bytes_to_hex_string(&self.nonce[..12]) + } else { + // 16-byte nonce (DM/0xChat) + bytes_to_hex_string(&self.nonce) + } + } + + /// Convert from regular Attachment (borrowed) + pub fn from_attachment(att: &Attachment) -> Self { + // Detect short nonce (12 bytes = 24 hex chars) for legacy attachments + let is_short_nonce = att.nonce.len() == 24; + let mut flags = AttachmentFlags::from_bools(att.downloading, att.downloaded); + flags.set_short_nonce(is_short_nonce); + + Self { + id: hex_to_bytes_32(&att.id), + key: if att.key.is_empty() { [0u8; 32] } else { hex_to_bytes_32(&att.key) }, + nonce: if att.nonce.is_empty() { [0u8; 16] } else { parse_nonce(&att.nonce) }, + size: att.size, + flags, + extension: att.extension.clone().into_boxed_str(), + url: att.url.clone().into_boxed_str(), + path: att.path.clone().into_boxed_str(), + img_meta: att.img_meta.clone().map(Box::new), + group_id: att.group_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))), + original_hash: att.original_hash.as_ref().map(|s| Box::new(hex_to_bytes_32(s))), + webxdc_topic: att.webxdc_topic.clone().map(|s| s.into_boxed_str()), + mls_filename: att.mls_filename.clone().map(|s| s.into_boxed_str()), + scheme_version: att.scheme_version.clone().map(|s| s.into_boxed_str()), + name: att.name.clone(), + } + } + + /// Convert from regular Attachment (owned) - zero-copy where possible + pub fn from_attachment_owned(att: Attachment) -> Self { + // Detect short nonce (12 bytes = 24 hex chars) for legacy attachments + let is_short_nonce = att.nonce.len() == 24; + let mut flags = AttachmentFlags::from_bools(att.downloading, att.downloaded); + flags.set_short_nonce(is_short_nonce); + + Self { + id: hex_to_bytes_32(&att.id), + key: if att.key.is_empty() { [0u8; 32] } else { hex_to_bytes_32(&att.key) }, + nonce: if att.nonce.is_empty() { [0u8; 16] } else { parse_nonce(&att.nonce) }, + size: att.size, + flags, + extension: att.extension.into_boxed_str(), + url: att.url.into_boxed_str(), + path: att.path.into_boxed_str(), + img_meta: att.img_meta.map(Box::new), + group_id: att.group_id.map(|s| Box::new(hex_to_bytes_32(&s))), + original_hash: att.original_hash.map(|s| Box::new(hex_to_bytes_32(&s))), + webxdc_topic: att.webxdc_topic.map(|s| s.into_boxed_str()), + mls_filename: att.mls_filename.map(|s| s.into_boxed_str()), + scheme_version: att.scheme_version.map(|s| s.into_boxed_str()), + name: att.name, + } + } + + /// Convert back to regular Attachment + pub fn to_attachment(&self) -> Attachment { + Attachment { + id: self.id_hex(), + key: self.key_hex(), + nonce: self.nonce_hex(), + extension: self.extension.to_string(), + name: self.name.clone(), + url: self.url.to_string(), + path: self.path.to_string(), + size: self.size, + img_meta: self.img_meta.as_ref().map(|b| (**b).clone()), + downloading: self.flags.is_downloading(), + downloaded: self.flags.is_downloaded(), + webxdc_topic: self.webxdc_topic.as_ref().map(|s| s.to_string()), + group_id: self.group_id.as_ref().map(|b| bytes_to_hex_32(b)), + original_hash: self.original_hash.as_ref().map(|b| bytes_to_hex_32(b)), + scheme_version: self.scheme_version.as_ref().map(|s| s.to_string()), + mls_filename: self.mls_filename.as_ref().map(|s| s.to_string()), + } + } +} + +/// Parse a hex nonce string into [u8; 16], left-aligned, zero-allocation. +/// Both DM (32 hex chars) and legacy (24 hex chars) nonces are decoded. +/// Short nonces are right-padded with '0' to reach 32 chars before decode. +#[inline] +fn parse_nonce(hex: &str) -> [u8; 16] { + hex_to_bytes_16(hex) +} + +// ============================================================================ +// Npub Interner +// ============================================================================ + +/// String interner for npubs using sorted Vec + binary search. +/// +/// Each unique npub is stored exactly once. Messages reference npubs by u16 index. +/// - `intern()`: O(log n) lookup + O(n) insert for new strings +/// - `resolve()`: O(1) by index +/// +/// Memory: ~2 bytes per npub for the sorted index, plus the strings themselves. +#[derive(Clone, Debug, Default)] +pub struct NpubInterner { + /// npubs in insertion order - index is the stable ID used by messages + npubs: Vec, + /// Indices into npubs, sorted alphabetically for binary search + sorted: Vec, +} + +/// Sentinel value for "no npub" (avoids Option overhead) +pub const NO_NPUB: u16 = u16::MAX; + +impl NpubInterner { + pub fn new() -> Self { + Self { + npubs: Vec::new(), + sorted: Vec::new(), + } + } + + /// Pre-allocate capacity for expected number of unique npubs + pub fn with_capacity(capacity: usize) -> Self { + Self { + npubs: Vec::with_capacity(capacity), + sorted: Vec::with_capacity(capacity), + } + } + + /// Intern an npub string, returning its stable index. + /// + /// If the npub already exists, returns the existing index. + /// If new, stores it and returns a new index. + pub fn intern(&mut self, npub: &str) -> u16 { + // Binary search in sorted order + let result = self.sorted.binary_search_by(|&idx| { + self.npubs[idx as usize].as_str().cmp(npub) + }); + + match result { + Ok(pos) => self.sorted[pos], // Found existing + Err(insert_pos) => { + // u16::MAX is the NO_NPUB sentinel; at/past it new indices would alias the + // sentinel then wrap, misattributing authors — degrade to authorless instead. + if self.npubs.len() >= NO_NPUB as usize { + return NO_NPUB; + } + // New npub - add to both vectors + let new_idx = self.npubs.len() as u16; + self.npubs.push(npub.to_string()); + self.sorted.insert(insert_pos, new_idx); + new_idx + } + } + } + + /// Intern an optional npub, returning NO_NPUB sentinel for None. + #[inline] + pub fn intern_opt(&mut self, npub: Option<&str>) -> u16 { + match npub { + Some(s) if !s.is_empty() => self.intern(s), + _ => NO_NPUB, + } + } + + /// Look up an npub without inserting. Returns its handle if already interned. + pub fn lookup(&self, npub: &str) -> Option { + self.sorted.binary_search_by(|&idx| { + self.npubs[idx as usize].as_str().cmp(npub) + }).ok().map(|pos| self.sorted[pos]) + } + + /// Resolve an index back to the npub string. + /// + /// Returns None for NO_NPUB sentinel or out-of-bounds index. + #[inline] + pub fn resolve(&self, idx: u16) -> Option<&str> { + if idx == NO_NPUB { + return None; + } + self.npubs.get(idx as usize).map(|s| s.as_str()) + } + + /// Number of unique npubs stored + #[inline] + pub fn len(&self) -> usize { + self.npubs.len() + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.npubs.is_empty() + } + + /// Total memory used by the interner (approximate) + pub fn memory_usage(&self) -> usize { + std::mem::size_of::() + + self.npubs.capacity() * std::mem::size_of::() + + self.npubs.iter().map(|s| s.capacity()).sum::() + + self.sorted.capacity() * std::mem::size_of::() + } +} + +// ============================================================================ +// Compact Message +// ============================================================================ + +/// Memory-efficient message with binary IDs and interned npubs. +/// +/// Compared to the regular `Message` struct: +/// - IDs use `[u8; 32]` instead of hex String (saves ~56 bytes each) +/// - npubs use u16 index into interner (saves ~85 bytes each) +/// - Booleans packed into MessageFlags (saves ~24 bytes + 2 for replied_to_has_attachment) +/// - Boxed optional IDs (replied_to, wrapper_id) save ~40 bytes when None +/// - Compact timestamp (u32 seconds since 2020) saves 4 bytes +/// - TinyVec for attachments/reactions (8 bytes vs 24 = saves 32 bytes) +/// - Box for content (8 bytes vs 24 = saves 16 bytes) +/// - Total savings: ~350+ bytes per message +#[derive(Clone, Debug)] +pub struct CompactMessage { + /// Message ID as binary (64 hex chars -> 32 bytes) + pub id: [u8; 32], + /// Timestamp in milliseconds (full precision for sub-second ordering) + pub at: u64, + /// Packed boolean flags (mine, pending, failed, replied_to_has_attachment) + pub flags: MessageFlags, + /// Index into NpubInterner for sender's npub (NO_NPUB if none) + pub npub_idx: u16, + /// Replied-to message ID (boxed - None for ~70% of messages saves 24 bytes) + pub replied_to: Option>, + /// Index into NpubInterner for replied-to author (NO_NPUB if none) + pub replied_to_npub_idx: u16, + /// Wrapper event ID for gift-wrapped messages (boxed - saves 25 bytes when None) + pub wrapper_id: Option>, + + // Variable-length fields - optimized for memory + /// Message content (Box = 16 bytes vs String's 24 bytes) + pub content: Box, + /// Content of replied-to message + pub replied_to_content: Option>, + /// File attachments (CompactAttachment = ~120 bytes vs Attachment's ~320 bytes) + pub attachments: TinyVec, + /// Emoji reactions (CompactReaction = ~82 bytes vs Reaction's ~292 bytes) + pub reactions: TinyVec, + /// Edit history - boxed since <1% of messages are edited (saves 16 bytes inline) + #[allow(clippy::box_collection)] + pub edit_history: Option>>, + /// Link preview metadata - boxed since ~216 bytes but rare (saves ~208 bytes) + pub preview_metadata: Option>, + /// NIP-30 emoji tags travelling with this rumor — boxed because the + /// vast majority of messages have none, so the cold path stays cheap. + #[allow(clippy::box_collection)] + pub emoji_tags: Option>>, +} + +impl CompactMessage { + /// Check if this message has a replied-to reference + #[inline] + pub fn has_reply(&self) -> bool { + self.replied_to.is_some() + } + + /// Check if this message has been edited + #[inline] + pub fn is_edited(&self) -> bool { + self.edit_history.is_some() + } + + /// Get the message ID as a string (hex for event IDs, "pending-..." for pending) + #[inline] + pub fn id_hex(&self) -> String { + decode_message_id(&self.id) + } + + /// Get the replied-to ID as a hex string, or empty if none + #[inline] + pub fn replied_to_hex(&self) -> String { + match &self.replied_to { + Some(id) => bytes_to_hex_32(id), + None => String::new(), + } + } + + /// Get wrapper ID as hex string if present + #[inline] + pub fn wrapper_id_hex(&self) -> Option { + self.wrapper_id.as_ref().map(|id| bytes_to_hex_32(id)) + } + + /// Get timestamp as milliseconds (for compatibility with frontend) + #[inline] + pub fn timestamp_ms(&self) -> u64 { + timestamp_from_compact(self.at) + } + + /// Apply an edit to this message. + /// + /// `emoji_tags` are the NIP-30 custom-emoji tags resolved from the new + /// content; they're adopted only when this edit is the newest revision so + /// an out-of-order older edit can't clobber the live content's emoji. + pub fn apply_edit(&mut self, new_content: String, edited_at: u64, emoji_tags: Vec) { + // Initialize edit history with original content if not present + if self.edit_history.is_none() { + self.edit_history = Some(Box::new(vec![EditEntry { + content: self.content.to_string(), + edited_at: self.timestamp_ms(), // Convert compact to ms + }])); + } + + let mut is_latest = true; + if let Some(ref mut history) = self.edit_history { + // Deduplicate: skip if we already have this edit + if history.iter().any(|e| e.edited_at == edited_at) { + return; + } + + // Add new edit to history + history.push(EditEntry { + content: new_content.clone(), + edited_at, + }); + + // Sort by timestamp + history.sort_by_key(|e| e.edited_at); + is_latest = history.last().map(|e| e.edited_at == edited_at).unwrap_or(true); + } + + // Only the newest revision drives the visible content + emoji. + if is_latest { + self.content = new_content.into_boxed_str(); + self.emoji_tags = if emoji_tags.is_empty() { None } else { Some(Box::new(emoji_tags)) }; + } + } + + /// Get replied_to_has_attachment from flags + #[inline] + pub fn replied_to_has_attachment(&self) -> Option { + self.flags.replied_to_has_attachment() + } + + /// Add a reaction to this message + /// Note: Since TinyVec is immutable, this rebuilds the entire reactions list + pub fn add_reaction(&mut self, reaction: Reaction, interner: &mut NpubInterner) -> bool { + // Convert to binary ID for comparison + let reaction_id = hex_to_bytes_32(&reaction.id); + + // Check if already exists + if self.reactions.iter().any(|r| r.id == reaction_id) { + return false; + } + + // Convert to compact and rebuild + let compact = CompactReaction::from_reaction_owned(reaction, interner); + let mut reactions = self.reactions.to_vec(); + reactions.push(compact); + self.reactions = TinyVec::from_vec(reactions); + true + } + + /// Remove a reaction by its hex event id. Returns true if one was removed. + pub fn remove_reaction(&mut self, reaction_id: &str) -> bool { + let target = hex_to_bytes_32(reaction_id); + if !self.reactions.iter().any(|r| r.id == target) { + return false; + } + let mut reactions = self.reactions.to_vec(); + reactions.retain(|r| r.id != target); + self.reactions = TinyVec::from_vec(reactions); + true + } + + // Flag accessors for compatibility + #[inline] + pub fn is_mine(&self) -> bool { self.flags.is_mine() } + #[inline] + pub fn is_pending(&self) -> bool { self.flags.is_pending() } + #[inline] + pub fn is_failed(&self) -> bool { self.flags.is_failed() } + + // Flag setters + #[inline] + pub fn set_pending(&mut self, value: bool) { self.flags.set_pending(value); } + #[inline] + pub fn set_failed(&mut self, value: bool) { self.flags.set_failed(value); } + #[inline] + pub fn set_mine(&mut self, value: bool) { self.flags.set_mine(value); } +} + +// ============================================================================ +// Compact Message Vec with Binary Search +// ============================================================================ + +/// Sorted message storage with O(log n) lookup by ID. +/// +/// Messages are stored sorted by timestamp. A separate index provides +/// O(log n) lookup by message ID using binary search. +#[derive(Clone, Debug, Default)] +pub struct CompactMessageVec { + /// Messages sorted by timestamp (ascending) + messages: Vec, + /// Index for ID lookup: (id, position in messages), sorted by id + id_index: Vec<([u8; 32], u32)>, +} + +impl CompactMessageVec { + pub fn new() -> Self { + Self { + messages: Vec::new(), + id_index: Vec::new(), + } + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + messages: Vec::with_capacity(capacity), + id_index: Vec::with_capacity(capacity), + } + } + + /// Number of messages + #[inline] + pub fn len(&self) -> usize { + self.messages.len() + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } + + /// Get all messages (sorted by timestamp) + #[inline] + pub fn messages(&self) -> &[CompactMessage] { + &self.messages + } + + /// Get a mutable reference to all messages + #[inline] + pub fn messages_mut(&mut self) -> &mut Vec { + &mut self.messages + } + + /// Iterate over messages (supports .rev()) + #[inline] + pub fn iter(&self) -> std::slice::Iter<'_, CompactMessage> { + self.messages.iter() + } + + /// Iterate over messages mutably + #[inline] + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, CompactMessage> { + self.messages.iter_mut() + } + + /// Get the last message + #[inline] + pub fn last(&self) -> Option<&CompactMessage> { + self.messages.last() + } + + /// Get last message timestamp (in milliseconds) + #[inline] + pub fn last_timestamp(&self) -> Option { + self.messages.last().map(|m| timestamp_from_compact(m.at)) + } + + /// Get the first message + #[inline] + pub fn first(&self) -> Option<&CompactMessage> { + self.messages.first() + } + + /// Find a message by ID using binary search - O(log n) + pub fn find_by_id(&self, id: &[u8; 32]) -> Option<&CompactMessage> { + let pos = self.id_index + .binary_search_by(|(idx_id, _)| idx_id.cmp(id)) + .ok()?; + let msg_pos = self.id_index[pos].1 as usize; + self.messages.get(msg_pos) + } + + /// Find a message by ID (mutable) - O(log n) + pub fn find_by_id_mut(&mut self, id: &[u8; 32]) -> Option<&mut CompactMessage> { + let pos = self.id_index + .binary_search_by(|(idx_id, _)| idx_id.cmp(id)) + .ok()?; + let msg_pos = self.id_index[pos].1 as usize; + self.messages.get_mut(msg_pos) + } + + /// Find a message by ID string (hex or pending) - O(log n) + pub fn find_by_hex_id(&self, id_str: &str) -> Option<&CompactMessage> { + if id_str.is_empty() { + return None; + } + let id = encode_message_id(id_str); + self.find_by_id(&id) + } + + /// Find a message by ID string (mutable) - O(log n) + pub fn find_by_hex_id_mut(&mut self, id_str: &str) -> Option<&mut CompactMessage> { + if id_str.is_empty() { + return None; + } + let id = encode_message_id(id_str); + self.find_by_id_mut(&id) + } + + /// Check if a message with the given ID exists - O(log n) + pub fn contains_id(&self, id: &[u8; 32]) -> bool { + self.id_index + .binary_search_by(|(idx_id, _)| idx_id.cmp(id)) + .is_ok() + } + + /// Remove a message by hex ID string. Returns true if removed. + pub fn remove_by_hex_id(&mut self, id_str: &str) -> bool { + if id_str.is_empty() { + return false; + } + let id = encode_message_id(id_str); + // Find position in id_index + let idx_pos = match self.id_index.binary_search_by(|(idx_id, _)| idx_id.cmp(&id)) { + Ok(pos) => pos, + Err(_) => return false, + }; + let msg_pos = self.id_index[idx_pos].1 as usize; + // Remove from messages vec + self.messages.remove(msg_pos); + // Rebuild index since positions shifted + self.rebuild_index(); + true + } + + /// Check if a message with the given ID string exists - O(log n) + pub fn contains_hex_id(&self, id_str: &str) -> bool { + if id_str.is_empty() { + return false; + } + let id = encode_message_id(id_str); + self.contains_id(&id) + } + + /// Insert a message, maintaining sort order by timestamp. + /// + /// Returns true if the message was added, false if duplicate ID. + /// + /// **Performance**: O(log n) for append (common case), O(n) for out-of-order insert. + pub fn insert(&mut self, msg: CompactMessage) -> bool { + // Check for duplicate ID - O(log n) + if self.contains_id(&msg.id) { + return false; + } + + let msg_id = msg.id; + + // Fast path: append if message is newer than or equal to last (common case) + // This is O(log n) for the index insert only + if self.messages.last().is_none_or(|last| msg.at >= last.at) { + let msg_pos = self.messages.len() as u32; + self.messages.push(msg); + + // Insert into id_index (maintain sorted order by ID) - O(log n) search + O(n) shift + // But the shift is typically small since IDs are random/sequential + let idx_pos = self.id_index + .binary_search_by(|(id, _)| id.cmp(&msg_id)) + .unwrap_err(); + self.id_index.insert(idx_pos, (msg_id, msg_pos)); + + return true; + } + + // Slow path: out-of-order insert (rare for real-time chat) + // Find insertion position by timestamp + let msg_pos = match self.messages.binary_search_by(|m| m.at.cmp(&msg.at)) { + Ok(pos) => pos, + Err(pos) => pos, + }; + + // Update id_index positions for messages that will shift - O(n) + for (_, pos) in &mut self.id_index { + if *pos >= msg_pos as u32 { + *pos += 1; + } + } + + // Insert into messages - O(n) + self.messages.insert(msg_pos, msg); + + // Insert into id_index - O(n) + let idx_pos = self.id_index + .binary_search_by(|(id, _)| id.cmp(&msg_id)) + .unwrap_err(); + self.id_index.insert(idx_pos, (msg_id, msg_pos as u32)); + + true + } + + /// Rebuild the ID index (call after bulk modifications) + pub fn rebuild_index(&mut self) { + self.id_index.clear(); + self.id_index.reserve(self.messages.len()); + for (pos, msg) in self.messages.iter().enumerate() { + self.id_index.push((msg.id, pos as u32)); + } + self.id_index.sort_by(|(a, _), (b, _)| a.cmp(b)); + } + + /// Batch insert messages - optimized for different scenarios. + /// + /// Returns the number of messages actually added (excludes duplicates). + /// + /// **Performance**: + /// - Append case (newer msgs): O(k log n) where k = new messages + /// - Prepend case (older msgs): O(k log n + k) + /// - Mixed: O(n log n) full sort + pub fn insert_batch(&mut self, messages: impl IntoIterator) -> usize { + let messages: Vec<_> = messages.into_iter().collect(); + if messages.is_empty() { + return 0; + } + + // Quick dedup check using the index + let mut to_add: Vec = Vec::with_capacity(messages.len()); + for msg in messages { + if !self.contains_id(&msg.id) { + to_add.push(msg); + } + } + + if to_add.is_empty() { + return 0; + } + + let added = to_add.len(); + + // Determine the insertion strategy based on timestamps + let our_first = self.messages.first().map(|m| m.at); + let our_last = self.messages.last().map(|m| m.at); + let their_min = to_add.iter().map(|m| m.at).min().unwrap(); + let their_max = to_add.iter().map(|m| m.at).max().unwrap(); + + if self.messages.is_empty() { + // Empty vec - just add and sort + self.messages = to_add; + self.messages.sort_by_key(|m| m.at); + self.rebuild_index(); + } else if their_min >= our_last.unwrap() { + // All new messages are NEWER - append path (common for real-time) + to_add.sort_by_key(|m| m.at); + let base_pos = self.messages.len() as u32; + for (i, msg) in to_add.into_iter().enumerate() { + let msg_id = msg.id; + self.messages.push(msg); + // Insert into index + let idx_pos = self.id_index + .binary_search_by(|(id, _)| id.cmp(&msg_id)) + .unwrap_err(); + self.id_index.insert(idx_pos, (msg_id, base_pos + i as u32)); + } + } else if their_max <= our_first.unwrap() { + // All new messages are OLDER - prepend path (common for pagination) + to_add.sort_by_key(|m| m.at); + let prepend_count = to_add.len(); + + // Shift all existing index positions + for (_, pos) in &mut self.id_index { + *pos += prepend_count as u32; + } + + // Build new index entries (already sorted by construction since to_add is sorted by timestamp) + let mut new_index_entries: Vec<_> = to_add.iter() + .enumerate() + .map(|(i, msg)| (msg.id, i as u32)) + .collect(); + new_index_entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + + // Merge sorted index entries in O(n + k) instead of O(k * n) + let old_index = std::mem::take(&mut self.id_index); + self.id_index.reserve(old_index.len() + new_index_entries.len()); + + let mut old_iter = old_index.into_iter().peekable(); + let mut new_iter = new_index_entries.into_iter().peekable(); + + while old_iter.peek().is_some() || new_iter.peek().is_some() { + match (old_iter.peek(), new_iter.peek()) { + (Some((old_id, _)), Some((new_id, _))) => { + if old_id < new_id { + self.id_index.push(old_iter.next().unwrap()); + } else { + self.id_index.push(new_iter.next().unwrap()); + } + } + (Some(_), None) => self.id_index.push(old_iter.next().unwrap()), + (None, Some(_)) => self.id_index.push(new_iter.next().unwrap()), + (None, None) => break, + } + } + + // Prepend messages + let mut new_messages = to_add; + new_messages.append(&mut self.messages); + self.messages = new_messages; + } else { + // Mixed timestamps - fall back to full sort + self.messages.extend(to_add); + self.messages.sort_by_key(|m| m.at); + self.rebuild_index(); + } + + added + } + + /// Total memory used (approximate) + pub fn memory_usage(&self) -> usize { + std::mem::size_of::() + + self.messages.capacity() * std::mem::size_of::() + + self.id_index.capacity() * std::mem::size_of::<([u8; 32], u32)>() + // Note: doesn't include heap allocations inside CompactMessage + } + + /// Drain messages from a range (rebuilds index after) + pub fn drain(&mut self, range: std::ops::Range) -> std::vec::Drain<'_, CompactMessage> { + let drain = self.messages.drain(range); + // Note: caller should call rebuild_index() after consuming the drain + drain + } + + /// Sort messages by a key (rebuilds index after) + pub fn sort_by_key(&mut self, f: F) + where + F: FnMut(&CompactMessage) -> K, + K: Ord, + { + self.messages.sort_by_key(f); + self.rebuild_index(); + } + + /// Clear all messages + pub fn clear(&mut self) { + self.messages.clear(); + self.id_index.clear(); + } +} + +// ============================================================================ +// Conversion from/to Message +// ============================================================================ + +use crate::types::Message; + +impl CompactMessage { + /// Convert from a regular Message (borrowed), interning npubs + pub fn from_message(msg: &Message, interner: &mut NpubInterner) -> Self { + Self { + id: encode_message_id(&msg.id), + at: timestamp_to_compact(msg.at), + flags: MessageFlags::from_all(msg.mine, msg.pending, msg.failed, msg.replied_to_has_attachment), + npub_idx: interner.intern_opt(msg.npub.as_deref()), + // Box replied_to only when present (saves 24 bytes when None) + replied_to: if msg.replied_to.is_empty() { + None + } else { + Some(Box::new(hex_to_bytes_32(&msg.replied_to))) + }, + replied_to_npub_idx: interner.intern_opt(msg.replied_to_npub.as_deref()), + // Box wrapper_id (saves 25 bytes when None) + wrapper_id: msg.wrapper_event_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))), + // Box for content (saves 8 bytes per field) + content: msg.content.clone().into_boxed_str(), + replied_to_content: msg.replied_to_content.as_ref().map(|s| s.clone().into_boxed_str()), + // Convert attachments to compact format + attachments: TinyVec::from_vec( + msg.attachments.iter() + .map(CompactAttachment::from_attachment) + .collect() + ), + // Convert reactions to compact format + reactions: TinyVec::from_vec( + msg.reactions.iter() + .map(|r| CompactReaction::from_reaction(r, interner)) + .collect() + ), + // Box rare fields to save inline space + edit_history: msg.edit_history.clone().map(Box::new), + preview_metadata: msg.preview_metadata.clone().map(Box::new), + emoji_tags: if msg.emoji_tags.is_empty() { + None + } else { + Some(Box::new(msg.emoji_tags.clone())) + }, + } + } + + /// Convert from a regular Message (owned) - ZERO-COPY for strings! + /// + /// Takes ownership of the Message and moves strings directly. + /// Use this when you don't need the original Message anymore. + pub fn from_message_owned(msg: Message, interner: &mut NpubInterner) -> Self { + Self { + id: encode_message_id(&msg.id), + at: timestamp_to_compact(msg.at), + flags: MessageFlags::from_all(msg.mine, msg.pending, msg.failed, msg.replied_to_has_attachment), + npub_idx: interner.intern_opt(msg.npub.as_deref()), + // Box replied_to only when present (saves 24 bytes when None) + replied_to: if msg.replied_to.is_empty() { + None + } else { + Some(Box::new(hex_to_bytes_32(&msg.replied_to))) + }, + replied_to_npub_idx: interner.intern_opt(msg.replied_to_npub.as_deref()), + // Box wrapper_id (saves 25 bytes when None) + wrapper_id: msg.wrapper_event_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))), + // Zero-copy: into_boxed_str() reuses the String's buffer! + content: msg.content.into_boxed_str(), + replied_to_content: msg.replied_to_content.map(|s| s.into_boxed_str()), + // Convert attachments to compact format (zero-copy where possible) + attachments: TinyVec::from_vec( + msg.attachments.into_iter() + .map(CompactAttachment::from_attachment_owned) + .collect() + ), + // Convert reactions to compact format (zero-copy for emoji string) + reactions: TinyVec::from_vec( + msg.reactions.into_iter() + .map(|r| CompactReaction::from_reaction_owned(r, interner)) + .collect() + ), + // Box rare fields to save inline space + edit_history: msg.edit_history.map(Box::new), + preview_metadata: msg.preview_metadata.map(Box::new), + emoji_tags: if msg.emoji_tags.is_empty() { + None + } else { + Some(Box::new(msg.emoji_tags)) + }, + } + } + + /// Convert back to a regular Message, resolving npubs from interner + pub fn to_message(&self, interner: &NpubInterner) -> Message { + Message { + id: self.id_hex(), + at: self.timestamp_ms(), // Convert compact back to ms + mine: self.flags.is_mine(), + pending: self.flags.is_pending(), + failed: self.flags.is_failed(), + edited: self.is_edited(), + npub: interner.resolve(self.npub_idx).map(|s| s.to_string()), + replied_to: self.replied_to_hex(), + replied_to_content: self.replied_to_content.as_ref().map(|s| s.to_string()), + replied_to_npub: interner.resolve(self.replied_to_npub_idx).map(|s| s.to_string()), + replied_to_has_attachment: self.flags.replied_to_has_attachment(), + // Re-resolved per get_message_views / populate_reply_context; the compact + // form keeps only the bool, so this stays None on the RAM path. + replied_to_attachment_extension: None, + wrapper_event_id: self.wrapper_id_hex(), + content: self.content.to_string(), + // Convert compact attachments back to regular Attachment + attachments: self.attachments.iter() + .map(|a| a.to_attachment()) + .collect(), + // Convert compact reactions back to regular Reaction + reactions: self.reactions.iter() + .map(|r| r.to_reaction(interner)) + .collect(), + // Unbox rare fields + edit_history: self.edit_history.as_ref().map(|b| (**b).clone()), + preview_metadata: self.preview_metadata.as_ref().map(|b| (**b).clone()), + emoji_tags: self.emoji_tags.as_ref().map(|b| (**b).clone()).unwrap_or_default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_message_flags() { + let mut flags = MessageFlags::NONE; + assert!(!flags.is_mine()); + assert!(!flags.is_pending()); + assert!(!flags.is_failed()); + + flags.set_mine(true); + assert!(flags.is_mine()); + + flags.set_pending(true); + assert!(flags.is_pending()); + assert!(flags.is_mine()); // Still set + + flags.set_mine(false); + assert!(!flags.is_mine()); + assert!(flags.is_pending()); // Still set + } + + #[test] + fn test_npub_interner() { + let mut interner = NpubInterner::new(); + + let idx1 = interner.intern("npub1alice"); + let idx2 = interner.intern("npub1bob"); + let idx3 = interner.intern("npub1alice"); // Duplicate + + assert_eq!(idx1, idx3); // Same string = same index + assert_ne!(idx1, idx2); + + assert_eq!(interner.resolve(idx1), Some("npub1alice")); + assert_eq!(interner.resolve(idx2), Some("npub1bob")); + assert_eq!(interner.resolve(NO_NPUB), None); + } + + #[test] + fn test_compact_message_vec_insert_and_find() { + let mut vec = CompactMessageVec::new(); + let mut interner = NpubInterner::new(); + + let msg1 = CompactMessage { + id: hex_to_bytes_32("0000000000000000000000000000000000000000000000000000000000000001"), + at: 1000, + flags: MessageFlags::NONE, + npub_idx: interner.intern("npub1test"), + replied_to: None, + replied_to_npub_idx: NO_NPUB, + wrapper_id: None, + content: "First message".to_string().into_boxed_str(), + replied_to_content: None, + attachments: TinyVec::new(), + reactions: TinyVec::new(), + edit_history: None, + preview_metadata: None, // Boxed, but None = 8 bytes + emoji_tags: None, + }; + + let msg2 = CompactMessage { + id: hex_to_bytes_32("0000000000000000000000000000000000000000000000000000000000000002"), + at: 2000, + flags: MessageFlags::MINE, + npub_idx: interner.intern("npub1me"), + replied_to: None, + replied_to_npub_idx: NO_NPUB, + wrapper_id: None, + content: "Second message".to_string().into_boxed_str(), + replied_to_content: None, + attachments: TinyVec::new(), + reactions: TinyVec::new(), + edit_history: None, + preview_metadata: None, // Boxed, but None = 8 bytes + emoji_tags: None, + }; + + assert!(vec.insert(msg1)); + assert!(vec.insert(msg2)); + assert_eq!(vec.len(), 2); + + // Find by ID + let found = vec.find_by_hex_id("0000000000000000000000000000000000000000000000000000000000000001"); + assert!(found.is_some()); + assert_eq!(&*found.unwrap().content, "First message"); + + // Find non-existent + let not_found = vec.find_by_hex_id("0000000000000000000000000000000000000000000000000000000000000099"); + assert!(not_found.is_none()); + } + + #[test] + fn test_duplicate_insert_rejected() { + let mut vec = CompactMessageVec::new(); + + let msg = CompactMessage { + id: hex_to_bytes_32("abcd000000000000000000000000000000000000000000000000000000000000"), + at: 1000, + flags: MessageFlags::NONE, + npub_idx: NO_NPUB, + replied_to: None, + replied_to_npub_idx: NO_NPUB, + wrapper_id: None, + content: "Test".to_string().into_boxed_str(), + replied_to_content: None, + attachments: TinyVec::new(), + reactions: TinyVec::new(), + edit_history: None, + preview_metadata: None, // Boxed + emoji_tags: None, + }; + + assert!(vec.insert(msg.clone())); + assert!(!vec.insert(msg)); // Duplicate rejected + assert_eq!(vec.len(), 1); + } + + /// Comprehensive benchmark test for memory reduction and performance + #[test] + fn benchmark_compact_vs_message() { + use std::time::Instant; + + const NUM_MESSAGES: usize = 10_000; + const NUM_UNIQUE_USERS: usize = 50; // Realistic chat scenario + + println!("\n========================================"); + println!(" COMPACT MESSAGE BENCHMARK"); + println!(" {} messages, {} unique users", NUM_MESSAGES, NUM_UNIQUE_USERS); + println!("========================================\n"); + + // Generate test data + let users: Vec = (0..NUM_UNIQUE_USERS) + .map(|i| format!("npub1{:0>62}", i)) + .collect(); + + // Create regular Messages + let messages: Vec = (0..NUM_MESSAGES) + .map(|i| { + let user_idx = i % NUM_UNIQUE_USERS; + Message { + id: format!("{:0>64x}", i), + at: 1700000000000 + (i as u64 * 1000), + mine: user_idx == 0, + pending: false, + failed: false, + edited: false, + npub: Some(users[user_idx].clone()), + replied_to: if i > 0 && i % 5 == 0 { + format!("{:0>64x}", i - 1) + } else { + String::new() + }, + replied_to_content: if i > 0 && i % 5 == 0 { + Some("Previous message content".to_string()) + } else { + None + }, + replied_to_npub: if i > 0 && i % 5 == 0 { + Some(users[(i - 1) % NUM_UNIQUE_USERS].clone()) + } else { + None + }, + replied_to_has_attachment: None, + replied_to_attachment_extension: None, + wrapper_event_id: Some(format!("{:0>64x}", i + 1000000)), + content: format!("This is message number {} with some typical content length.", i), + attachments: vec![], + reactions: vec![], + edit_history: None, + preview_metadata: None, + emoji_tags: Vec::new(), + } + }) + .collect(); + + // ===== MEMORY COMPARISON ===== + println!("--- STRUCT SIZES ---"); + println!(" Message struct: {} bytes", std::mem::size_of::()); + println!(" CompactMessage struct: {} bytes", std::mem::size_of::()); + println!(" Savings per struct: {} bytes ({:.1}%)", + std::mem::size_of::().saturating_sub(std::mem::size_of::()), + (1.0 - std::mem::size_of::() as f64 / std::mem::size_of::() as f64) * 100.0 + ); + println!(); + + // Measure Message storage (simulating Vec) + let msg_heap_estimate: usize = messages.iter().map(|m| { + m.id.capacity() + + m.npub.as_ref().map(|s| s.capacity()).unwrap_or(0) + + m.replied_to.capacity() + + m.replied_to_content.as_ref().map(|s| s.capacity()).unwrap_or(0) + + m.replied_to_npub.as_ref().map(|s| s.capacity()).unwrap_or(0) + + m.wrapper_event_id.as_ref().map(|s| s.capacity()).unwrap_or(0) + + m.content.capacity() + }).sum(); + let msg_total = messages.len() * std::mem::size_of::() + msg_heap_estimate; + + // ===== CONVERSION + INSERT BENCHMARK ===== + println!("--- INSERT BENCHMARK ---"); + + // Test 1: Sequential inserts (simulates real-time message arrival) + let mut interner = NpubInterner::with_capacity(NUM_UNIQUE_USERS); + let mut compact_vec = CompactMessageVec::with_capacity(NUM_MESSAGES); + + let insert_start = Instant::now(); + for msg in &messages { + let compact = CompactMessage::from_message(msg, &mut interner); + compact_vec.insert(compact); + } + let insert_elapsed = insert_start.elapsed(); + + println!(" Sequential insert (optimized append path):"); + println!(" {} messages in {:?}", NUM_MESSAGES, insert_elapsed); + println!(" Rate: {:.0} msgs/sec", NUM_MESSAGES as f64 / insert_elapsed.as_secs_f64()); + println!(" Per message: {:.3} us ({} ns)", + insert_elapsed.as_micros() as f64 / NUM_MESSAGES as f64, + insert_elapsed.as_nanos() / NUM_MESSAGES as u128); + println!(); + + // Test 2: Batch insert (simulates pagination/history loading) + let mut interner2 = NpubInterner::with_capacity(NUM_UNIQUE_USERS); + let mut compact_vec2 = CompactMessageVec::with_capacity(NUM_MESSAGES); + + let batch_start = Instant::now(); + let compact_messages: Vec<_> = messages.iter() + .map(|msg| CompactMessage::from_message(msg, &mut interner2)) + .collect(); + let batch_added = compact_vec2.insert_batch(compact_messages); + let batch_elapsed = batch_start.elapsed(); + + println!(" Batch insert (pagination/history load):"); + println!(" {} messages in {:?}", batch_added, batch_elapsed); + println!(" Rate: {:.0} msgs/sec", NUM_MESSAGES as f64 / batch_elapsed.as_secs_f64()); + println!(" Per message: {:.3} us ({} ns)", + batch_elapsed.as_micros() as f64 / NUM_MESSAGES as f64, + batch_elapsed.as_nanos() / NUM_MESSAGES as u128); + println!(); + + // ===== COMPACT MEMORY USAGE ===== + println!("--- MEMORY COMPARISON ---"); + let compact_heap_estimate: usize = compact_vec.iter().map(|m| { + m.content.len() // Box has no capacity, just len + + m.replied_to_content.as_ref().map(|s| s.len()).unwrap_or(0) + + m.attachments.len() * std::mem::size_of::() + if m.attachments.is_empty() { 0 } else { 1 } + + m.reactions.len() * std::mem::size_of::() + if m.reactions.is_empty() { 0 } else { 1 } + }).sum(); + let compact_struct_mem = compact_vec.len() * std::mem::size_of::(); + let compact_index_mem = compact_vec.len() * std::mem::size_of::<([u8; 32], u32)>(); + let interner_mem = interner.memory_usage(); + let compact_total = compact_struct_mem + compact_heap_estimate + compact_index_mem + interner_mem; + + println!(" Regular Message storage:"); + println!(" Struct memory: {:>10} bytes", messages.len() * std::mem::size_of::()); + println!(" Heap (strings): {:>10} bytes", msg_heap_estimate); + println!(" TOTAL: {:>10} bytes ({:.2} MB)", msg_total, msg_total as f64 / 1_000_000.0); + println!(); + println!(" CompactMessage storage:"); + println!(" Struct memory: {:>10} bytes", compact_struct_mem); + println!(" Heap (strings): {:>10} bytes", compact_heap_estimate); + println!(" ID index: {:>10} bytes", compact_index_mem); + println!(" Interner: {:>10} bytes ({} unique npubs)", interner_mem, interner.len()); + println!(" TOTAL: {:>10} bytes ({:.2} MB)", compact_total, compact_total as f64 / 1_000_000.0); + println!(); + println!(" SAVINGS: {} bytes ({:.1}%)", + msg_total.saturating_sub(compact_total), + (1.0 - compact_total as f64 / msg_total as f64) * 100.0 + ); + println!(" Per message: {} -> {} bytes (avg)", + msg_total / NUM_MESSAGES, + compact_total / NUM_MESSAGES + ); + println!(); + + // ===== LOOKUP BENCHMARK ===== + println!("--- LOOKUP BENCHMARK ---"); + + // Generate random lookup IDs (mix of existing and non-existing) + let lookup_ids: Vec = (0..1000) + .map(|i| format!("{:0>64x}", i * 10)) // Every 10th message + .collect(); + + // Benchmark binary search lookup (CompactMessageVec) + let lookup_start = Instant::now(); + let mut found_count = 0; + for _ in 0..100 { // 100 iterations + for id in &lookup_ids { + if compact_vec.find_by_hex_id(id).is_some() { + found_count += 1; + } + } + } + let lookup_elapsed = lookup_start.elapsed(); + let total_lookups = 100 * lookup_ids.len(); + + println!(" Binary search (CompactMessageVec):"); + println!(" {} lookups in {:?}", total_lookups, lookup_elapsed); + println!(" Rate: {:.0} lookups/sec", total_lookups as f64 / lookup_elapsed.as_secs_f64()); + println!(" Per lookup: {:.2} us", lookup_elapsed.as_micros() as f64 / total_lookups as f64); + println!(" Found: {} / {}", found_count, total_lookups); + println!(); + + // Benchmark linear search (simulating Vec) + let linear_start = Instant::now(); + let mut linear_found = 0; + for _ in 0..100 { + for id in &lookup_ids { + if messages.iter().find(|m| &m.id == id).is_some() { + linear_found += 1; + } + } + } + let linear_elapsed = linear_start.elapsed(); + + println!(" Linear search (Vec):"); + println!(" {} lookups in {:?}", total_lookups, linear_elapsed); + println!(" Rate: {:.0} lookups/sec", total_lookups as f64 / linear_elapsed.as_secs_f64()); + println!(" Per lookup: {:.2} us", linear_elapsed.as_micros() as f64 / total_lookups as f64); + println!(); + + let speedup = linear_elapsed.as_nanos() as f64 / lookup_elapsed.as_nanos() as f64; + println!(" SPEEDUP: {:.1}x faster with binary search!", speedup); + println!(); + + // ===== INTERNER EFFICIENCY ===== + println!("--- INTERNER EFFICIENCY ---"); + let npub_string_size = 63 + 1; // "npub1" + 58 chars + null + let naive_npub_mem = NUM_MESSAGES * npub_string_size * 2; // npub + replied_to_npub + let actual_npub_mem = interner_mem; + println!(" Naive (every msg stores npubs): {} bytes", naive_npub_mem); + println!(" Interned ({} unique): {} bytes", interner.len(), actual_npub_mem); + println!(" SAVINGS: {} bytes ({:.1}%)", + naive_npub_mem.saturating_sub(actual_npub_mem), + (1.0 - actual_npub_mem as f64 / naive_npub_mem as f64) * 100.0 + ); + println!(); + + println!("========================================"); + println!(" BENCHMARK COMPLETE"); + println!("========================================\n"); + + // Verify correctness + assert_eq!(compact_vec.len(), NUM_MESSAGES); + assert_eq!(interner.len(), NUM_UNIQUE_USERS); + assert_eq!(found_count, linear_found); + } + + /// Benchmark: Profile lookup -- linear scan vs string binary search vs handle binary search + /// + /// Compares three approaches for finding a Profile in a Vec: + /// 1. Linear scan with string equality (old -- O(n) x 63-byte strcmp, Profile.id was String) + /// 2. Binary search by npub string (intermediate -- O(log n) x 63-byte strcmp) + /// 3. Direct u16 handle binary search (current -- O(log n) x 2-byte int cmp, Profile.id is u16) + #[test] + fn benchmark_profile_lookup() { + use std::time::Instant; + use std::hint::black_box; + + const NUM_PROFILES: usize = 60; + const NUM_LOOKUPS: usize = 100_000; + + println!("\n========================================"); + println!(" PROFILE LOOKUP BENCHMARK"); + println!(" {} profiles, {} lookups each method", NUM_PROFILES, NUM_LOOKUPS); + println!("========================================\n"); + + // Generate realistic npubs (63 chars each: "npub1" + 58 hex-like chars) + let npubs: Vec = (0..NUM_PROFILES) + .map(|i| format!("npub1{:0>58}", format!("{:x}", i * 7919 + 1000))) // spread out values + .collect(); + + // --- Setup: Method 1 - Linear scan (old approach, simulating id: String) --- + let old_ids: Vec = npubs.iter().rev().cloned().collect(); // reversed = worst case + + // --- Setup: Method 2 - String binary search (intermediate approach) --- + let mut sorted_ids: Vec = npubs.clone(); + sorted_ids.sort(); + + // --- Setup: Method 3 - Direct u16 handle lookup (current approach, id: u16) --- + let mut interner = NpubInterner::new(); + let mut profiles: Vec = npubs.iter().map(|npub| { + let mut p = crate::profile::Profile::new(); + p.id = interner.intern(npub); + p + }).collect(); + profiles.sort_by(|a, b| a.id.cmp(&b.id)); + + // Build lookup targets: cycle through all profiles + let lookup_targets: Vec<&str> = (0..NUM_LOOKUPS) + .map(|i| npubs[i % NUM_PROFILES].as_str()) + .collect(); + + // Pre-resolve handles for method 3 + let handle_targets: Vec = lookup_targets.iter() + .map(|&npub| interner.lookup(npub).unwrap()) + .collect(); + + // ===== BENCHMARK 1: Linear scan (old -- id: String) ===== + let start = Instant::now(); + let mut found = 0u64; + for &target in &lookup_targets { + if old_ids.iter().any(|id| id == target) { + found += 1; + } + } + let linear_elapsed = start.elapsed(); + assert_eq!(found, NUM_LOOKUPS as u64); + + // ===== BENCHMARK 2: String binary search (intermediate) ===== + let start = Instant::now(); + found = 0; + for &target in &lookup_targets { + if sorted_ids.binary_search_by(|id| id.as_str().cmp(target)).is_ok() { + found += 1; + } + } + let string_bs_elapsed = start.elapsed(); + assert_eq!(found, NUM_LOOKUPS as u64); + + // ===== BENCHMARK 3: Direct u16 handle lookup (current -- id: u16) ===== + let start = Instant::now(); + found = 0; + for &handle in &handle_targets { + if profiles.binary_search_by(|p| p.id.cmp(black_box(&handle))).is_ok() { + found += 1; + } + } + let direct_elapsed = start.elapsed(); + assert_eq!(found, NUM_LOOKUPS as u64); + + // ===== RESULTS ===== + println!("--- LOOKUP METHODS ---"); + println!(" 1. Linear scan (old id: String):"); + println!(" {:?} total, {:.0} ns/lookup", + linear_elapsed, + linear_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64); + println!(); + println!(" 2. String binary search (intermediate):"); + println!(" {:?} total, {:.0} ns/lookup", + string_bs_elapsed, + string_bs_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64); + println!(" vs linear: {:.1}x faster", + linear_elapsed.as_nanos() as f64 / string_bs_elapsed.as_nanos() as f64); + println!(); + println!(" 3. Direct u16 handle lookup (current id: u16):"); + println!(" {:?} total, {:.0} ns/lookup", + direct_elapsed, + direct_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64); + println!(" vs linear: {:.1}x faster", + linear_elapsed.as_nanos() as f64 / direct_elapsed.as_nanos() as f64); + println!(" vs string BS: {:.1}x faster", + string_bs_elapsed.as_nanos() as f64 / direct_elapsed.as_nanos() as f64); + println!(); + + // ===== MEMORY COMPARISON ===== + println!("--- MEMORY PER PROFILE ---"); + println!(" Old (id: String): ~87 bytes (24 String header + ~63 heap)"); + println!(" Current (id: u16): 2 bytes (inline)"); + println!(" Savings: ~85 bytes/profile, ~{} bytes for {} profiles", + 85 * NUM_PROFILES, NUM_PROFILES); + println!(" Interner (shared): {} bytes (shared with message system)", + interner.memory_usage()); + println!(); + + println!("========================================"); + println!(" BENCHMARK COMPLETE"); + println!("========================================\n"); + + // Correctness: ensure all methods find the same profiles + for npub in &npubs { + assert!(old_ids.iter().any(|id| id == npub)); + assert!(sorted_ids.binary_search_by(|id| id.as_str().cmp(npub.as_str())).is_ok()); + let h = interner.lookup(npub).unwrap(); + assert!(profiles.binary_search_by(|p| p.id.cmp(&h)).is_ok()); + } + } + + // ======================================================================== + // Pending ID Encoding Tests + // ======================================================================== + + #[test] + fn pending_id_roundtrip_regular_hex() { + let hex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + let encoded = encode_message_id(hex); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, hex, "regular hex ID should roundtrip exactly"); + } + + #[test] + fn pending_id_roundtrip_pending() { + let id = "pending-1234567890123456789"; + let encoded = encode_message_id(id); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, id, "pending ID should roundtrip exactly"); + } + + #[test] + fn pending_id_zero_timestamp() { + let id = "pending-0"; + let encoded = encode_message_id(id); + assert_eq!(encoded[0], PENDING_ID_MARKER, "first byte should be marker"); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, id, "pending-0 should roundtrip"); + } + + #[test] + fn pending_id_max_u128_timestamp() { + let max = u128::MAX; + let id = format!("pending-{}", max); + let encoded = encode_message_id(&id); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, id, "pending with max u128 should roundtrip"); + } + + #[test] + fn pending_id_all_zero_hex() { + let hex = "0000000000000000000000000000000000000000000000000000000000000000"; + let encoded = encode_message_id(hex); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, hex, "all-zero hex ID should roundtrip"); + // All-zero should NOT be detected as pending since byte 0 is 0x00, not 0x01 + assert_ne!(encoded[0], PENDING_ID_MARKER); + } + + #[test] + fn pending_id_all_ff_hex() { + let hex = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + let encoded = encode_message_id(hex); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, hex, "all-ff hex ID should roundtrip"); + assert_eq!(encoded, [0xff; 32], "all-ff should decode to all 0xff bytes"); + } + + #[test] + fn pending_id_mixed_case_hex() { + let lower = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + let mixed = "ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789"; + let encoded_lower = encode_message_id(lower); + let encoded_mixed = encode_message_id(mixed); + assert_eq!(encoded_lower, encoded_mixed, "mixed case should produce same bytes as lowercase"); + } + + #[test] + fn pending_id_short_hex_partial_decode() { + // SIMD hex_to_bytes_32 partially decodes short input (decodes what it can) + let short = "abcdef"; + let encoded = encode_message_id(short); + // Short input is decoded as far as possible, remaining bytes are zero + assert_eq!(encoded[0..14], [0u8; 14], "leading bytes should be zero for short input"); + } + + #[test] + fn pending_id_marker_distinguishes_from_real_id() { + // A real event ID starting with 01 should NOT be confused with pending + let hex = "0100000000000000000000000000000000000000000000000000000000000000"; + let encoded = encode_message_id(hex); + // The first byte is 0x01 which matches PENDING_ID_MARKER - but decode_message_id + // will treat it as pending. This is by design since real SHA256 IDs with first + // byte 0x01 are extremely rare and the probability is 1/256. + let decoded = decode_message_id(&encoded); + // This will decode as pending since byte[0] == 0x01 + assert!(decoded.starts_with("pending-"), "ID starting with 0x01 byte is treated as pending by design"); + } + + #[test] + fn pending_id_large_timestamp() { + let id = "pending-99999999999999999"; + let encoded = encode_message_id(&id); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, id, "large but valid timestamp should roundtrip"); + } + + #[test] + fn pending_id_invalid_timestamp_becomes_zero() { + // If the timestamp part can't be parsed, it stays as all-zeros in bytes 1..17 + let id = "pending-notanumber"; + let encoded = encode_message_id(id); + assert_eq!(encoded[0], PENDING_ID_MARKER); + // Bytes 1..17 should all be 0 + assert_eq!(&encoded[1..17], &[0u8; 16]); + let decoded = decode_message_id(&encoded); + assert_eq!(decoded, "pending-0", "invalid timestamp parses as pending-0"); + } + + // ======================================================================== + // Timestamp Tests + // ======================================================================== + + #[test] + fn timestamp_to_compact_and_back_roundtrip() { + // A representative timestamp: 2024-01-15 12:00:00 UTC in milliseconds + let ms: u64 = 1705320000000; + let compact = timestamp_to_compact(ms); + let restored = timestamp_from_compact(compact); + // The sub-second part is lost (ms -> seconds -> ms), so restored is floored to seconds + assert_eq!(restored / 1000, ms / 1000, "roundtrip should preserve seconds"); + } + + #[test] + fn secs_to_compact_and_back_roundtrip() { + let secs: u64 = 1705320000; // 2024-01-15 12:00:00 UTC + let compact = secs_to_compact(secs); + let restored = secs_from_compact(compact); + assert_eq!(restored, secs, "secs should roundtrip exactly"); + } + + #[test] + fn timestamp_zero_preservation() { + // Zero is a sentinel for "never set" in secs_to_compact/secs_from_compact + assert_eq!(secs_to_compact(0), 0, "zero secs should produce zero compact"); + assert_eq!(secs_from_compact(0), 0, "zero compact should produce zero secs"); + } + + #[test] + fn timestamp_epoch_boundary() { + // Exactly 2020-01-01 00:00:00 UTC = EPOCH_2020_SECS + let epoch_secs: u64 = 1577836800; + let compact = secs_to_compact(epoch_secs); + assert_eq!(compact, 0, "epoch boundary should map to compact 0"); + let restored = secs_from_compact(compact); + // secs_from_compact(0) returns 0 (sentinel), not epoch + assert_eq!(restored, 0, "compact 0 returns sentinel 0"); + } + + #[test] + fn timestamp_epoch_boundary_ms() { + let epoch_ms: u64 = 1577836800000; + let compact = timestamp_to_compact(epoch_ms); + assert_eq!(compact, epoch_ms, "full u64 — identity function"); + let restored = timestamp_from_compact(compact); + assert_eq!(restored, epoch_ms, "roundtrip preserves value"); + } + + #[test] + fn timestamp_current_time_roundtrip() { + // Simulate a current-ish timestamp: 2026-03-27 in seconds + let secs: u64 = 1774800000; + let compact = secs_to_compact(secs); + let restored = secs_from_compact(compact); + assert_eq!(restored, secs, "current-era timestamp should roundtrip"); + assert!(compact > 0, "current time should be past epoch"); + } + + #[test] + fn timestamp_far_future_year_2100() { + // 2100-01-01 00:00:00 UTC + let secs: u64 = 4102444800; + let compact = secs_to_compact(secs); + let restored = secs_from_compact(compact); + assert_eq!(restored, secs, "year 2100 should roundtrip"); + // Verify it fits in u32 + assert!(compact <= u32::MAX, "year 2100 should fit in u32"); + } + + #[test] + fn timestamp_pre_epoch_saturates_to_zero() { + // A timestamp before 2020 epoch + let secs: u64 = 1500000000; // ~2017 + let compact = secs_to_compact(secs); + // saturating_sub means this becomes 0 + assert_eq!(compact, 0, "pre-epoch timestamp should saturate to 0"); + } + + #[test] + fn timestamp_ms_sub_second_precision_preserved() { + let ms: u64 = 1705320000999; + let compact = timestamp_to_compact(ms); + let restored = timestamp_from_compact(compact); + assert_eq!(restored, ms, "sub-second ms must be preserved"); + } + + #[test] + fn timestamp_one_second_after_epoch() { + let secs: u64 = 1577836801; // one second after epoch + let compact = secs_to_compact(secs); + assert_eq!(compact, 1, "one second after epoch should be compact 1"); + let restored = secs_from_compact(compact); + assert_eq!(restored, secs, "should restore to original"); + } + + // ======================================================================== + // MessageFlags Tests + // ======================================================================== + + #[test] + fn message_flags_mine_independent() { + let mut flags = MessageFlags::NONE; + flags.set_mine(true); + assert!(flags.is_mine(), "mine should be set"); + assert!(!flags.is_pending(), "pending should not be set"); + assert!(!flags.is_failed(), "failed should not be set"); + } + + #[test] + fn message_flags_pending_independent() { + let mut flags = MessageFlags::NONE; + flags.set_pending(true); + assert!(!flags.is_mine(), "mine should not be set"); + assert!(flags.is_pending(), "pending should be set"); + assert!(!flags.is_failed(), "failed should not be set"); + } + + #[test] + fn message_flags_failed_independent() { + let mut flags = MessageFlags::NONE; + flags.set_failed(true); + assert!(!flags.is_mine(), "mine should not be set"); + assert!(!flags.is_pending(), "pending should not be set"); + assert!(flags.is_failed(), "failed should be set"); + } + + #[test] + fn message_flags_from_bools_all_false() { + let flags = MessageFlags::from_bools(false, false, false); + assert!(!flags.is_mine()); + assert!(!flags.is_pending()); + assert!(!flags.is_failed()); + assert_eq!(flags, MessageFlags::NONE, "all false should equal NONE"); + } + + #[test] + fn message_flags_from_bools_all_true() { + let flags = MessageFlags::from_bools(true, true, true); + assert!(flags.is_mine(), "mine should be set"); + assert!(flags.is_pending(), "pending should be set"); + assert!(flags.is_failed(), "failed should be set"); + } + + #[test] + fn message_flags_from_bools_various_combos() { + let flags = MessageFlags::from_bools(true, false, true); + assert!(flags.is_mine()); + assert!(!flags.is_pending()); + assert!(flags.is_failed()); + + let flags = MessageFlags::from_bools(false, true, false); + assert!(!flags.is_mine()); + assert!(flags.is_pending()); + assert!(!flags.is_failed()); + } + + #[test] + fn message_flags_from_all_replied_to_none() { + let flags = MessageFlags::from_all(false, false, false, None); + assert_eq!(flags.replied_to_has_attachment(), None, "None should roundtrip"); + } + + #[test] + fn message_flags_from_all_replied_to_some_false() { + let flags = MessageFlags::from_all(false, false, false, Some(false)); + assert_eq!(flags.replied_to_has_attachment(), Some(false), "Some(false) should roundtrip"); + } + + #[test] + fn message_flags_from_all_replied_to_some_true() { + let flags = MessageFlags::from_all(false, false, false, Some(true)); + assert_eq!(flags.replied_to_has_attachment(), Some(true), "Some(true) should roundtrip"); + } + + #[test] + fn message_flags_multiple_set_simultaneously() { + let flags = MessageFlags::from_all(true, true, false, Some(true)); + assert!(flags.is_mine()); + assert!(flags.is_pending()); + assert!(!flags.is_failed()); + assert_eq!(flags.replied_to_has_attachment(), Some(true)); + } + + #[test] + fn message_flags_default_is_all_false() { + let flags = MessageFlags::default(); + assert!(!flags.is_mine()); + assert!(!flags.is_pending()); + assert!(!flags.is_failed()); + assert_eq!(flags.replied_to_has_attachment(), None); + assert_eq!(flags, MessageFlags::NONE); + } + + #[test] + fn message_flags_bit_patterns_correct() { + assert_eq!(MessageFlags::MINE.0, 0b00001, "MINE bit pattern"); + assert_eq!(MessageFlags::PENDING.0, 0b00010, "PENDING bit pattern"); + assert_eq!(MessageFlags::FAILED.0, 0b00100, "FAILED bit pattern"); + } + + #[test] + fn message_flags_set_then_clear() { + let mut flags = MessageFlags::from_bools(true, true, true); + flags.set_mine(false); + assert!(!flags.is_mine(), "mine should be cleared"); + assert!(flags.is_pending(), "pending should remain set"); + assert!(flags.is_failed(), "failed should remain set"); + } + + #[test] + fn message_flags_replied_to_overwrite() { + let mut flags = MessageFlags::from_all(false, false, false, Some(true)); + assert_eq!(flags.replied_to_has_attachment(), Some(true)); + flags.set_replied_to_has_attachment(Some(false)); + assert_eq!(flags.replied_to_has_attachment(), Some(false), "overwrite should work"); + flags.set_replied_to_has_attachment(None); + assert_eq!(flags.replied_to_has_attachment(), None, "clearing to None should work"); + } + + #[test] + fn message_flags_replied_to_does_not_interfere_with_other_bits() { + let mut flags = MessageFlags::from_bools(true, true, true); + flags.set_replied_to_has_attachment(Some(true)); + assert!(flags.is_mine(), "mine should still be set"); + assert!(flags.is_pending(), "pending should still be set"); + assert!(flags.is_failed(), "failed should still be set"); + assert_eq!(flags.replied_to_has_attachment(), Some(true)); + } + + // ======================================================================== + // AttachmentFlags Tests + // ======================================================================== + + #[test] + fn attachment_flags_downloading_independent() { + let mut flags = AttachmentFlags::NONE; + flags.set_downloading(true); + assert!(flags.is_downloading()); + assert!(!flags.is_downloaded()); + assert!(!flags.is_short_nonce()); + } + + #[test] + fn attachment_flags_downloaded_independent() { + let mut flags = AttachmentFlags::NONE; + flags.set_downloaded(true); + assert!(!flags.is_downloading()); + assert!(flags.is_downloaded()); + assert!(!flags.is_short_nonce()); + } + + #[test] + fn attachment_flags_short_nonce_independent() { + let mut flags = AttachmentFlags::NONE; + flags.set_short_nonce(true); + assert!(!flags.is_downloading()); + assert!(!flags.is_downloaded()); + assert!(flags.is_short_nonce()); + } + + #[test] + fn attachment_flags_from_bools() { + let flags = AttachmentFlags::from_bools(true, false); + assert!(flags.is_downloading()); + assert!(!flags.is_downloaded()); + + let flags = AttachmentFlags::from_bools(false, true); + assert!(!flags.is_downloading()); + assert!(flags.is_downloaded()); + } + + #[test] + fn attachment_flags_all_set() { + let mut flags = AttachmentFlags::NONE; + flags.set_downloading(true); + flags.set_downloaded(true); + flags.set_short_nonce(true); + assert!(flags.is_downloading()); + assert!(flags.is_downloaded()); + assert!(flags.is_short_nonce()); + } + + #[test] + fn attachment_flags_set_then_clear() { + let mut flags = AttachmentFlags::NONE; + flags.set_downloading(true); + flags.set_downloaded(true); + flags.set_downloading(false); + assert!(!flags.is_downloading(), "downloading should be cleared"); + assert!(flags.is_downloaded(), "downloaded should remain set"); + } + + #[test] + fn attachment_flags_default_none() { + let flags = AttachmentFlags::NONE; + assert!(!flags.is_downloading()); + assert!(!flags.is_downloaded()); + assert!(!flags.is_short_nonce()); + assert_eq!(flags, AttachmentFlags::default()); + } + + #[test] + fn attachment_flags_bit_values() { + // Verify the bit constants are distinct + let mut flags = AttachmentFlags::NONE; + flags.set_downloading(true); + assert_eq!(flags.0, 0b0001); + + let mut flags = AttachmentFlags::NONE; + flags.set_downloaded(true); + assert_eq!(flags.0, 0b0010); + + let mut flags = AttachmentFlags::NONE; + flags.set_short_nonce(true); + assert_eq!(flags.0, 0b0100); + } + + // ======================================================================== + // NpubInterner Tests + // ======================================================================== + + #[test] + fn interner_returns_incrementing_handles() { + let mut interner = NpubInterner::new(); + let h0 = interner.intern("npub1aaa"); + let h1 = interner.intern("npub1bbb"); + let h2 = interner.intern("npub1ccc"); + assert_eq!(h0, 0, "first intern should be handle 0"); + assert_eq!(h1, 1, "second intern should be handle 1"); + assert_eq!(h2, 2, "third intern should be handle 2"); + } + + #[test] + fn interner_lookup_finds_interned() { + let mut interner = NpubInterner::new(); + let h = interner.intern("npub1alice"); + let found = interner.lookup("npub1alice"); + assert_eq!(found, Some(h), "lookup should find interned string"); + } + + #[test] + fn interner_lookup_returns_none_for_unknown() { + let interner = NpubInterner::new(); + assert_eq!(interner.lookup("npub1unknown"), None, "lookup on empty interner should be None"); + } + + #[test] + fn interner_lookup_returns_none_for_not_interned() { + let mut interner = NpubInterner::new(); + interner.intern("npub1alice"); + assert_eq!(interner.lookup("npub1bob"), None, "lookup for non-interned should be None"); + } + + #[test] + fn interner_resolve_returns_string() { + let mut interner = NpubInterner::new(); + let h = interner.intern("npub1test123"); + assert_eq!(interner.resolve(h), Some("npub1test123"), "resolve should return the original string"); + } + + #[test] + fn interner_resolve_returns_none_for_no_npub() { + let interner = NpubInterner::new(); + assert_eq!(interner.resolve(NO_NPUB), None, "resolve(NO_NPUB) should be None"); + } + + #[test] + fn interner_resolve_returns_none_for_out_of_bounds() { + let mut interner = NpubInterner::new(); + interner.intern("npub1only"); + assert_eq!(interner.resolve(999), None, "out-of-bounds handle should resolve to None"); + } + + #[test] + fn interner_duplicate_returns_same_handle() { + let mut interner = NpubInterner::new(); + let h1 = interner.intern("npub1dup"); + let h2 = interner.intern("npub1dup"); + let h3 = interner.intern("npub1dup"); + assert_eq!(h1, h2, "duplicate intern should return same handle"); + assert_eq!(h2, h3, "duplicate intern should return same handle"); + assert_eq!(interner.len(), 1, "duplicates should not increase length"); + } + + #[test] + fn interner_100_unique_npubs_stress() { + let mut interner = NpubInterner::new(); + let mut handles = Vec::new(); + + for i in 0..100 { + let npub = format!("npub1stress{:04}", i); + let h = interner.intern(&npub); + handles.push((h, npub)); + } + + assert_eq!(interner.len(), 100, "should have 100 unique npubs"); + + // Verify all handles resolve correctly + for (h, npub) in &handles { + assert_eq!(interner.resolve(*h), Some(npub.as_str()), + "handle {} should resolve to {}", h, npub); + } + + // Verify all lookups work + for (h, npub) in &handles { + assert_eq!(interner.lookup(npub), Some(*h), + "lookup for {} should return handle {}", npub, h); + } + + // Re-interning should return same handles + for (h, npub) in &handles { + assert_eq!(interner.intern(npub), *h, + "re-interning {} should return same handle {}", npub, h); + } + assert_eq!(interner.len(), 100, "re-interning should not grow interner"); + } + + #[test] + fn interner_memory_usage_reasonable() { + let mut interner = NpubInterner::new(); + for i in 0..50 { + interner.intern(&format!("npub1{:0>62}", i)); + } + let mem = interner.memory_usage(); + // Should be in the ballpark of 50 * (64 bytes string + overhead) + assert!(mem > 0, "memory usage should be positive"); + assert!(mem < 100_000, "memory usage for 50 npubs should be under 100KB, was {}", mem); + } + + #[test] + fn interner_empty() { + let interner = NpubInterner::new(); + assert_eq!(interner.len(), 0); + assert!(interner.is_empty()); + assert_eq!(interner.resolve(0), None); + assert_eq!(interner.lookup("anything"), None); + assert!(interner.memory_usage() > 0, "even empty interner has struct overhead"); + } + + #[test] + fn interner_intern_opt_none() { + let mut interner = NpubInterner::new(); + let h = interner.intern_opt(None); + assert_eq!(h, NO_NPUB, "intern_opt(None) should return NO_NPUB"); + assert_eq!(interner.len(), 0, "None should not add to interner"); + } + + #[test] + fn interner_intern_opt_empty_string() { + let mut interner = NpubInterner::new(); + let h = interner.intern_opt(Some("")); + assert_eq!(h, NO_NPUB, "intern_opt(Some('')) should return NO_NPUB"); + } + + #[test] + fn interner_intern_opt_some_value() { + let mut interner = NpubInterner::new(); + let h = interner.intern_opt(Some("npub1real")); + assert_ne!(h, NO_NPUB, "intern_opt(Some(value)) should not return NO_NPUB"); + assert_eq!(interner.resolve(h), Some("npub1real")); + } + + // ======================================================================== + // CompactMessageVec Tests + // ======================================================================== + + /// Helper to create a minimal CompactMessage with given hex ID and timestamp + fn make_compact_msg(hex_id: &str, timestamp: u64) -> CompactMessage { + CompactMessage { + id: encode_message_id(hex_id), + at: timestamp, + flags: MessageFlags::NONE, + npub_idx: NO_NPUB, + replied_to: None, + replied_to_npub_idx: NO_NPUB, + wrapper_id: None, + content: "test".into(), + replied_to_content: None, + attachments: TinyVec::new(), + reactions: TinyVec::new(), + edit_history: None, + preview_metadata: None, + emoji_tags: None, + } + } + + #[test] + fn compact_vec_insert_single_message() { + let mut vec = CompactMessageVec::new(); + let msg = make_compact_msg( + "1111111111111111111111111111111111111111111111111111111111111111", + 100, + ); + assert!(vec.insert(msg), "insert should succeed"); + assert_eq!(vec.len(), 1); + assert!(!vec.is_empty()); + } + + #[test] + fn compact_vec_insert_duplicate_rejected() { + let mut vec = CompactMessageVec::new(); + let id = "2222222222222222222222222222222222222222222222222222222222222222"; + let msg1 = make_compact_msg(id, 100); + let msg2 = make_compact_msg(id, 200); // same ID, different timestamp + assert!(vec.insert(msg1), "first insert should succeed"); + assert!(!vec.insert(msg2), "duplicate ID should be rejected"); + assert_eq!(vec.len(), 1); + } + + #[test] + fn compact_vec_insert_batch_multiple() { + let mut vec = CompactMessageVec::new(); + let msgs = vec![ + make_compact_msg("aa00000000000000000000000000000000000000000000000000000000000000", 100), + make_compact_msg("bb00000000000000000000000000000000000000000000000000000000000000", 200), + make_compact_msg("cc00000000000000000000000000000000000000000000000000000000000000", 300), + ]; + let added = vec.insert_batch(msgs); + assert_eq!(added, 3, "all 3 should be added"); + assert_eq!(vec.len(), 3); + } + + #[test] + fn compact_vec_insert_batch_dedup() { + let mut vec = CompactMessageVec::new(); + let id = "dd00000000000000000000000000000000000000000000000000000000000000"; + vec.insert(make_compact_msg(id, 100)); + + let msgs = vec![ + make_compact_msg(id, 200), // duplicate + make_compact_msg("ee00000000000000000000000000000000000000000000000000000000000000", 300), + ]; + let added = vec.insert_batch(msgs); + assert_eq!(added, 1, "only non-duplicate should be added"); + assert_eq!(vec.len(), 2); + } + + #[test] + fn compact_vec_find_by_hex_id() { + let mut vec = CompactMessageVec::new(); + let id = "ff00000000000000000000000000000000000000000000000000000000000001"; + let mut msg = make_compact_msg(id, 500); + msg.content = "found me".into(); + vec.insert(msg); + + let found = vec.find_by_hex_id(id); + assert!(found.is_some(), "should find by hex id"); + assert_eq!(&*found.unwrap().content, "found me"); + } + + #[test] + fn compact_vec_find_by_hex_id_not_found() { + let mut vec = CompactMessageVec::new(); + vec.insert(make_compact_msg( + "aa00000000000000000000000000000000000000000000000000000000000000", 100, + )); + let found = vec.find_by_hex_id( + "bb00000000000000000000000000000000000000000000000000000000000000", + ); + assert!(found.is_none(), "should not find non-existent ID"); + } + + #[test] + fn compact_vec_find_by_hex_id_empty_string() { + let mut vec = CompactMessageVec::new(); + vec.insert(make_compact_msg( + "aa00000000000000000000000000000000000000000000000000000000000000", 100, + )); + assert!(vec.find_by_hex_id("").is_none(), "empty string should return None"); + } + + #[test] + fn compact_vec_find_by_hex_id_mut() { + let mut vec = CompactMessageVec::new(); + let id = "ff00000000000000000000000000000000000000000000000000000000000002"; + vec.insert(make_compact_msg(id, 500)); + + let found = vec.find_by_hex_id_mut(id); + assert!(found.is_some(), "should find mutable ref by hex id"); + found.unwrap().content = "modified".into(); + + // Verify modification stuck + let found = vec.find_by_hex_id(id); + assert_eq!(&*found.unwrap().content, "modified"); + } + + #[test] + fn compact_vec_contains_hex_id() { + let mut vec = CompactMessageVec::new(); + let id = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + vec.insert(make_compact_msg(id, 100)); + + assert!(vec.contains_hex_id(id), "should contain inserted ID"); + assert!(!vec.contains_hex_id( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "should not contain non-inserted ID"); + assert!(!vec.contains_hex_id(""), "empty string should not match"); + } + + #[test] + fn compact_vec_remove_by_hex_id() { + let mut vec = CompactMessageVec::new(); + let id1 = "1100000000000000000000000000000000000000000000000000000000000000"; + let id2 = "2200000000000000000000000000000000000000000000000000000000000000"; + vec.insert(make_compact_msg(id1, 100)); + vec.insert(make_compact_msg(id2, 200)); + assert_eq!(vec.len(), 2); + + assert!(vec.remove_by_hex_id(id1), "remove should succeed"); + assert_eq!(vec.len(), 1); + assert!(!vec.contains_hex_id(id1), "removed ID should not be found"); + assert!(vec.contains_hex_id(id2), "remaining ID should still be found"); + } + + #[test] + fn compact_vec_remove_nonexistent() { + let mut vec = CompactMessageVec::new(); + vec.insert(make_compact_msg( + "1100000000000000000000000000000000000000000000000000000000000000", 100, + )); + assert!(!vec.remove_by_hex_id( + "9900000000000000000000000000000000000000000000000000000000000000"), + "removing non-existent should return false"); + assert!(!vec.remove_by_hex_id(""), "removing empty should return false"); + assert_eq!(vec.len(), 1); + } + + #[test] + fn compact_vec_last_timestamp() { + let mut vec = CompactMessageVec::new(); + assert_eq!(vec.last_timestamp(), None, "empty vec should have no last timestamp"); + + vec.insert(make_compact_msg( + "aa00000000000000000000000000000000000000000000000000000000000000", 100, + )); + vec.insert(make_compact_msg( + "bb00000000000000000000000000000000000000000000000000000000000000", 300, + )); + vec.insert(make_compact_msg( + "cc00000000000000000000000000000000000000000000000000000000000000", 200, + )); + + let last_ts = vec.last_timestamp().unwrap(); + // Messages are sorted by timestamp, so last should be the one with at=300 + let expected = timestamp_from_compact(300); + assert_eq!(last_ts, expected, "last timestamp should be the largest"); + } + + #[test] + fn compact_vec_empty_operations() { + let vec = CompactMessageVec::new(); + assert!(vec.is_empty()); + assert_eq!(vec.len(), 0); + assert!(vec.last().is_none()); + assert!(vec.first().is_none()); + assert!(vec.last_timestamp().is_none()); + assert!(!vec.contains_hex_id("anything")); + assert!(vec.find_by_hex_id("anything").is_none()); + } + + #[test] + fn compact_vec_1000_message_stress() { + let mut vec = CompactMessageVec::new(); + + // Insert 1000 messages + for i in 0..1000u64 { + let id = format!("{:0>64x}", i); + let msg = make_compact_msg(&id, i * 10); + assert!(vec.insert(msg), "insert {} should succeed", i); + } + assert_eq!(vec.len(), 1000); + + // Verify every message can be found + for i in 0..1000u64 { + let id = format!("{:0>64x}", i); + assert!(vec.contains_hex_id(&id), "should find message {}", i); + let found = vec.find_by_hex_id(&id).unwrap(); + assert_eq!(found.at, i * 10, "timestamp should match for message {}", i); + } + + // Verify non-existent IDs are not found + for i in 1000..1010u64 { + let id = format!("{:0>64x}", i); + assert!(!vec.contains_hex_id(&id), "should not find non-existent {}", i); + } + + // Messages should be in timestamp order + let timestamps: Vec = vec.iter().map(|m| m.at).collect(); + for w in timestamps.windows(2) { + assert!(w[0] <= w[1], "messages should be sorted by timestamp: {} <= {}", w[0], w[1]); + } + } + + #[test] + fn compact_vec_rebuild_index_after_id_change() { + let mut vec = CompactMessageVec::new(); + let old_id = "aa00000000000000000000000000000000000000000000000000000000000000"; + let new_id = "ff00000000000000000000000000000000000000000000000000000000000000"; + vec.insert(make_compact_msg(old_id, 100)); + + // Mutate the message's ID directly (simulating an ID update like pending -> confirmed) + vec.messages_mut()[0].id = encode_message_id(new_id); + // Index is now stale + assert!(!vec.contains_hex_id(new_id), "stale index should not find new ID"); + + // Rebuild index + vec.rebuild_index(); + assert!(vec.contains_hex_id(new_id), "after rebuild, new ID should be found"); + assert!(!vec.contains_hex_id(old_id), "after rebuild, old ID should not be found"); + } + + #[test] + fn compact_vec_pending_id_lookup() { + let mut vec = CompactMessageVec::new(); + let pending = "pending-9876543210"; + vec.insert(make_compact_msg(pending, 500)); + + assert!(vec.contains_hex_id(pending), "should find pending ID"); + let found = vec.find_by_hex_id(pending); + assert!(found.is_some(), "should find pending message"); + assert_eq!(found.unwrap().id_hex(), pending, "id_hex should match pending string"); + } + + #[test] + fn compact_vec_out_of_order_insert() { + let mut vec = CompactMessageVec::new(); + // Insert messages out of timestamp order + vec.insert(make_compact_msg( + "bb00000000000000000000000000000000000000000000000000000000000000", 300, + )); + vec.insert(make_compact_msg( + "aa00000000000000000000000000000000000000000000000000000000000000", 100, + )); + vec.insert(make_compact_msg( + "cc00000000000000000000000000000000000000000000000000000000000000", 200, + )); + + assert_eq!(vec.len(), 3); + // Verify sorted by timestamp + let timestamps: Vec = vec.iter().map(|m| m.at).collect(); + assert_eq!(timestamps, vec![100, 200, 300], "should be sorted by timestamp"); + + // All lookups should still work + assert!(vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000")); + assert!(vec.contains_hex_id("bb00000000000000000000000000000000000000000000000000000000000000")); + assert!(vec.contains_hex_id("cc00000000000000000000000000000000000000000000000000000000000000")); + } + + #[test] + fn compact_vec_batch_prepend() { + let mut vec = CompactMessageVec::new(); + // First insert newer messages + vec.insert(make_compact_msg( + "cc00000000000000000000000000000000000000000000000000000000000000", 300, + )); + vec.insert(make_compact_msg( + "dd00000000000000000000000000000000000000000000000000000000000000", 400, + )); + + // Then batch-insert older messages (pagination scenario) + let older = vec![ + make_compact_msg("aa00000000000000000000000000000000000000000000000000000000000000", 100), + make_compact_msg("bb00000000000000000000000000000000000000000000000000000000000000", 200), + ]; + let added = vec.insert_batch(older); + assert_eq!(added, 2); + assert_eq!(vec.len(), 4); + + // Verify order + let timestamps: Vec = vec.iter().map(|m| m.at).collect(); + assert_eq!(timestamps, vec![100, 200, 300, 400]); + + // All lookups should work + assert!(vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000")); + assert!(vec.contains_hex_id("dd00000000000000000000000000000000000000000000000000000000000000")); + } + + #[test] + fn compact_vec_clear() { + let mut vec = CompactMessageVec::new(); + vec.insert(make_compact_msg( + "aa00000000000000000000000000000000000000000000000000000000000000", 100, + )); + vec.insert(make_compact_msg( + "bb00000000000000000000000000000000000000000000000000000000000000", 200, + )); + assert_eq!(vec.len(), 2); + + vec.clear(); + assert!(vec.is_empty()); + assert_eq!(vec.len(), 0); + assert!(!vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000")); + } + + // ======================================================================== + // CompactMessage from_message / to_message Tests + // ======================================================================== + + /// Helper to create a full Message with all fields populated + fn make_full_message() -> Message { + Message { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + content: "Hello, world!".into(), + replied_to: "1111111111111111111111111111111111111111111111111111111111111111".into(), + replied_to_content: Some("Original message".into()), + replied_to_npub: Some("npub1replier".into()), + replied_to_has_attachment: Some(true), + replied_to_attachment_extension: None, + preview_metadata: Some(SiteMetadata { + domain: "example.com".into(), + og_title: Some("Test Page".into()), + og_description: Some("A test description".into()), + og_image: Some("https://example.com/img.png".into()), + og_url: Some("https://example.com".into()), + og_type: Some("website".into()), + title: Some("Test".into()), + description: Some("Desc".into()), + favicon: Some("https://example.com/favicon.ico".into()), + }), + attachments: vec![Attachment { + id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(), + key: "bbbb000000000000000000000000000000000000000000000000000000000000".into(), + nonce: "cccccccccccccccccccccccccccccccc".into(), // 32 hex chars = 16 bytes + extension: "png".into(), + name: "photo.png".into(), + url: "https://blossom.example.com".into(), + path: "/tmp/photo.png".into(), + size: 12345, + img_meta: Some(ImageMetadata { + thumbhash: "abc123".into(), + width: 800, + height: 600, + }), + downloading: false, + downloaded: true, + webxdc_topic: None, + group_id: None, + original_hash: None, + scheme_version: None, + mls_filename: None, + }], + reactions: vec![Reaction { + id: "dddd000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + author_id: "npub1reactor".into(), + emoji: "\u{1f44d}".into(), // thumbs up + emoji_url: None, + }], + at: 1705320000000, // 2024-01-15 12:00:00 UTC ms + pending: false, + failed: false, + mine: true, + npub: Some("npub1sender".into()), + wrapper_event_id: Some("eeee000000000000000000000000000000000000000000000000000000000000".into()), + edited: true, + edit_history: Some(vec![ + EditEntry { content: "Original".into(), edited_at: 1705320000000 }, + EditEntry { content: "Edited".into(), edited_at: 1705320060000 }, + ]), + emoji_tags: Vec::new(), + } + } + + #[test] + fn compact_message_from_message_roundtrip_all_fields() { + let msg = make_full_message(); + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + let restored = compact.to_message(&interner); + + assert_eq!(restored.id, msg.id, "id mismatch"); + assert_eq!(restored.content, msg.content, "content mismatch"); + assert_eq!(restored.mine, msg.mine, "mine mismatch"); + assert_eq!(restored.pending, msg.pending, "pending mismatch"); + assert_eq!(restored.failed, msg.failed, "failed mismatch"); + assert_eq!(restored.npub, msg.npub, "npub mismatch"); + assert_eq!(restored.replied_to, msg.replied_to, "replied_to mismatch"); + assert_eq!(restored.replied_to_content, msg.replied_to_content, "replied_to_content mismatch"); + assert_eq!(restored.replied_to_npub, msg.replied_to_npub, "replied_to_npub mismatch"); + assert_eq!(restored.replied_to_has_attachment, msg.replied_to_has_attachment, "replied_to_has_attachment mismatch"); + assert_eq!(restored.wrapper_event_id, msg.wrapper_event_id, "wrapper_event_id mismatch"); + assert_eq!(restored.edited, msg.edited, "edited mismatch"); + assert_eq!(restored.edit_history, msg.edit_history, "edit_history mismatch"); + assert_eq!(restored.preview_metadata, msg.preview_metadata, "preview_metadata mismatch"); + // Timestamp loses sub-second precision but seconds should match + assert_eq!(restored.at / 1000, msg.at / 1000, "timestamp seconds mismatch"); + // Attachments + assert_eq!(restored.attachments.len(), 1, "should have 1 attachment"); + assert_eq!(restored.attachments[0].id, msg.attachments[0].id); + assert_eq!(restored.attachments[0].name, msg.attachments[0].name); + assert_eq!(restored.attachments[0].size, msg.attachments[0].size); + // Reactions + assert_eq!(restored.reactions.len(), 1, "should have 1 reaction"); + assert_eq!(restored.reactions[0].emoji, msg.reactions[0].emoji); + } + + #[test] + fn compact_message_from_message_owned_roundtrip() { + let msg = make_full_message(); + let msg_clone = msg.clone(); + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message_owned(msg, &mut interner); + let restored = compact.to_message(&interner); + + assert_eq!(restored.id, msg_clone.id, "id mismatch"); + assert_eq!(restored.content, msg_clone.content, "content mismatch"); + assert_eq!(restored.mine, msg_clone.mine, "mine mismatch"); + assert_eq!(restored.npub, msg_clone.npub, "npub mismatch"); + assert_eq!(restored.edit_history, msg_clone.edit_history, "edit_history mismatch"); + } + + #[test] + fn compact_message_pending_flag() { + let msg = Message { + id: "pending-1234567890".into(), + pending: true, + ..Message::default() + }; + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + assert!(compact.is_pending(), "pending flag should be set"); + let restored = compact.to_message(&interner); + assert!(restored.pending, "pending should roundtrip"); + assert_eq!(restored.id, "pending-1234567890", "pending ID should roundtrip"); + } + + #[test] + fn compact_message_failed_flag() { + let msg = Message { + id: "pending-999".into(), + failed: true, + pending: true, + ..Message::default() + }; + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + assert!(compact.is_failed(), "failed flag should be set"); + assert!(compact.is_pending(), "pending flag should also be set"); + let restored = compact.to_message(&interner); + assert!(restored.failed); + assert!(restored.pending); + } + + #[test] + fn compact_message_with_attachments_roundtrip() { + let msg = Message { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + attachments: vec![ + Attachment { + id: "1111111111111111111111111111111111111111111111111111111111111111".into(), + extension: "jpg".into(), + name: "sunset.jpg".into(), + size: 5000, + downloaded: true, + ..Attachment::default() + }, + Attachment { + id: "2222222222222222222222222222222222222222222222222222222222222222".into(), + extension: "mp4".into(), + name: "video.mp4".into(), + size: 50000, + downloaded: false, + downloading: true, + ..Attachment::default() + }, + ], + ..Message::default() + }; + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + assert_eq!(compact.attachments.len(), 2); + + let restored = compact.to_message(&interner); + assert_eq!(restored.attachments.len(), 2); + assert_eq!(restored.attachments[0].name, "sunset.jpg"); + assert_eq!(restored.attachments[0].extension, "jpg"); + assert!(restored.attachments[0].downloaded); + assert_eq!(restored.attachments[1].name, "video.mp4"); + assert!(restored.attachments[1].downloading); + assert!(!restored.attachments[1].downloaded); + } + + #[test] + fn compact_message_with_reactions_roundtrip() { + let msg = Message { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + reactions: vec![ + Reaction { + id: "aaa0000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + author_id: "npub1alice".into(), + emoji: "\u{2764}".into(), // heart + emoji_url: None, + }, + Reaction { + id: "bbb0000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + author_id: "npub1bob".into(), + emoji: "\u{1f525}".into(), // fire + emoji_url: None, + }, + ], + ..Message::default() + }; + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + let restored = compact.to_message(&interner); + + assert_eq!(restored.reactions.len(), 2); + assert_eq!(restored.reactions[0].emoji, "\u{2764}"); + assert_eq!(restored.reactions[0].author_id, "npub1alice"); + assert_eq!(restored.reactions[1].emoji, "\u{1f525}"); + assert_eq!(restored.reactions[1].author_id, "npub1bob"); + } + + #[test] + fn compact_message_with_edit_history_roundtrip() { + let msg = Message { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + content: "Final version".into(), + edited: true, + edit_history: Some(vec![ + EditEntry { content: "First draft".into(), edited_at: 1000 }, + EditEntry { content: "Second draft".into(), edited_at: 2000 }, + EditEntry { content: "Final version".into(), edited_at: 3000 }, + ]), + ..Message::default() + }; + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + assert!(compact.is_edited()); + + let restored = compact.to_message(&interner); + assert!(restored.edited); + let history = restored.edit_history.unwrap(); + assert_eq!(history.len(), 3); + assert_eq!(history[0].content, "First draft"); + assert_eq!(history[2].content, "Final version"); + } + + #[test] + fn compact_message_with_preview_metadata_roundtrip() { + let meta = SiteMetadata { + domain: "example.com".into(), + og_title: Some("Title".into()), + og_description: Some("Desc".into()), + og_image: Some("https://example.com/img.png".into()), + og_url: None, + og_type: None, + title: None, + description: None, + favicon: None, + }; + let msg = Message { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + preview_metadata: Some(meta.clone()), + ..Message::default() + }; + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + let restored = compact.to_message(&interner); + + let restored_meta = restored.preview_metadata.unwrap(); + assert_eq!(restored_meta.domain, "example.com"); + assert_eq!(restored_meta.og_title, Some("Title".into())); + assert_eq!(restored_meta.og_image, Some("https://example.com/img.png".into())); + assert_eq!(restored_meta.og_url, None); + } + + #[test] + fn compact_message_with_replied_to_roundtrip() { + let msg = Message { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + replied_to: "1111111111111111111111111111111111111111111111111111111111111111".into(), + replied_to_content: Some("Original text".into()), + replied_to_npub: Some("npub1original".into()), + replied_to_has_attachment: Some(false), + ..Message::default() + }; + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + assert!(compact.has_reply()); + + let restored = compact.to_message(&interner); + assert_eq!(restored.replied_to, "1111111111111111111111111111111111111111111111111111111111111111"); + assert_eq!(restored.replied_to_content, Some("Original text".into())); + assert_eq!(restored.replied_to_npub, Some("npub1original".into())); + assert_eq!(restored.replied_to_has_attachment, Some(false)); + } + + #[test] + fn compact_message_empty_roundtrip() { + let msg = Message::default(); + let mut interner = NpubInterner::new(); + let compact = CompactMessage::from_message(&msg, &mut interner); + let restored = compact.to_message(&interner); + + assert_eq!(restored.id, "0000000000000000000000000000000000000000000000000000000000000000", + "empty ID should decode as all zeros hex"); + assert_eq!(restored.content, ""); + assert!(!restored.mine); + assert!(!restored.pending); + assert!(!restored.failed); + assert!(!restored.edited); + assert_eq!(restored.npub, None); + assert!(restored.replied_to.is_empty() || restored.replied_to == "0000000000000000000000000000000000000000000000000000000000000000"); + assert_eq!(restored.replied_to_content, None); + assert_eq!(restored.replied_to_npub, None); + assert_eq!(restored.replied_to_has_attachment, None); + assert_eq!(restored.wrapper_event_id, None); + assert!(restored.attachments.is_empty()); + assert!(restored.reactions.is_empty()); + assert_eq!(restored.edit_history, None); + assert_eq!(restored.preview_metadata, None); + } + + // ======================================================================== + // CompactAttachment Tests + // ======================================================================== + + #[test] + fn compact_attachment_from_attachment_roundtrip() { + let att = Attachment { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + key: "1111111111111111111111111111111111111111111111111111111111111111".into(), + nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), // 32 hex = 16-byte DM nonce + extension: "zip".into(), + name: "archive.zip".into(), + url: "https://blossom.test.com".into(), + path: "/downloads/archive.zip".into(), + size: 99999, + img_meta: None, + downloading: false, + downloaded: true, + webxdc_topic: None, + group_id: None, + original_hash: None, + scheme_version: None, + mls_filename: None, + }; + + let compact = CompactAttachment::from_attachment(&att); + let restored = compact.to_attachment(); + + assert_eq!(restored.id, att.id, "id mismatch"); + assert_eq!(restored.key, att.key, "key mismatch"); + assert_eq!(restored.nonce, att.nonce, "nonce mismatch"); + assert_eq!(restored.extension, att.extension, "extension mismatch"); + assert_eq!(restored.name, att.name, "name mismatch"); + assert_eq!(restored.url, att.url, "url mismatch"); + assert_eq!(restored.path, att.path, "path mismatch"); + assert_eq!(restored.size, att.size, "size mismatch"); + assert_eq!(restored.downloading, att.downloading, "downloading mismatch"); + assert_eq!(restored.downloaded, att.downloaded, "downloaded mismatch"); + } + + #[test] + fn compact_attachment_from_attachment_owned_roundtrip() { + let att = Attachment { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + key: "1111111111111111111111111111111111111111111111111111111111111111".into(), + nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), + extension: "pdf".into(), + name: "document.pdf".into(), + url: "https://server.com".into(), + path: "".into(), + size: 1024, + img_meta: None, + downloading: false, + downloaded: false, + webxdc_topic: None, + group_id: None, + original_hash: None, + scheme_version: None, + mls_filename: None, + }; + let att_clone = att.clone(); + + let compact = CompactAttachment::from_attachment_owned(att); + let restored = compact.to_attachment(); + + assert_eq!(restored.id, att_clone.id); + assert_eq!(restored.name, att_clone.name); + assert_eq!(restored.size, att_clone.size); + } + + #[test] + fn compact_attachment_key_nonce_zeros() { + let att = Attachment { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + key: "".into(), // empty key = legacy derived + nonce: "".into(), // empty nonce + ..Attachment::default() + }; + let compact = CompactAttachment::from_attachment(&att); + assert_eq!(compact.key, [0u8; 32], "empty key should be all zeros"); + assert_eq!(compact.nonce, [0u8; 16], "empty nonce should be all zeros"); + + let restored = compact.to_attachment(); + assert_eq!(restored.key, "", "zero key should restore as empty string"); + assert_eq!(restored.nonce, "", "zero nonce should restore as empty string"); + } + + #[test] + fn compact_attachment_short_nonce_mls_12byte() { + // Legacy short nonce is 12 bytes = 24 hex chars + let att = Attachment { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + nonce: "aabbccddaabbccddaabbccdd".into(), // 24 hex chars = 12 bytes + ..Attachment::default() + }; + let compact = CompactAttachment::from_attachment(&att); + assert!(compact.flags.is_short_nonce(), "12-byte nonce should set short_nonce flag"); + + let restored = compact.to_attachment(); + assert_eq!(restored.nonce, "aabbccddaabbccddaabbccdd", "short nonce should roundtrip"); + } + + #[test] + fn compact_attachment_long_nonce_dm_16byte() { + // DM nonce is 16 bytes = 32 hex chars + let att = Attachment { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), // 32 hex chars = 16 bytes + ..Attachment::default() + }; + let compact = CompactAttachment::from_attachment(&att); + assert!(!compact.flags.is_short_nonce(), "16-byte nonce should NOT set short_nonce flag"); + + let restored = compact.to_attachment(); + assert_eq!(restored.nonce, "aabbccddaabbccddaabbccddaabbccdd", "long nonce should roundtrip"); + } + + #[test] + fn compact_attachment_id_eq_comparison() { + let att = Attachment { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + ..Attachment::default() + }; + let compact = CompactAttachment::from_attachment(&att); + + assert!(compact.id_eq("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"), + "id_eq should match same hex"); + assert!(!compact.id_eq("1111111111111111111111111111111111111111111111111111111111111111"), + "id_eq should not match different hex"); + } + + #[test] + fn compact_attachment_all_optional_fields_none() { + let att = Attachment::default(); + let compact = CompactAttachment::from_attachment(&att); + + assert!(compact.img_meta.is_none()); + assert!(compact.group_id.is_none()); + assert!(compact.original_hash.is_none()); + assert!(compact.webxdc_topic.is_none()); + assert!(compact.mls_filename.is_none()); + assert!(compact.scheme_version.is_none()); + + let restored = compact.to_attachment(); + assert!(restored.img_meta.is_none()); + assert!(restored.group_id.is_none()); + assert!(restored.original_hash.is_none()); + assert!(restored.webxdc_topic.is_none()); + assert!(restored.mls_filename.is_none()); + assert!(restored.scheme_version.is_none()); + } + + #[test] + fn compact_attachment_all_optional_fields_some() { + let att = Attachment { + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(), + key: "1111111111111111111111111111111111111111111111111111111111111111".into(), + nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), + extension: "xdc".into(), + name: "app.xdc".into(), + url: "https://server.com/file".into(), + path: "/local/app.xdc".into(), + size: 50000, + img_meta: Some(ImageMetadata { + thumbhash: "hash123".into(), + width: 1920, + height: 1080, + }), + downloading: false, + downloaded: true, + webxdc_topic: Some("game-state".into()), + group_id: Some("cccc000000000000000000000000000000000000000000000000000000000000".into()), + original_hash: Some("dddd000000000000000000000000000000000000000000000000000000000000".into()), + scheme_version: Some("mip04-v1".into()), + mls_filename: Some("encrypted.bin".into()), + }; + + let compact = CompactAttachment::from_attachment(&att); + + assert!(compact.img_meta.is_some()); + assert!(compact.group_id.is_some()); + assert!(compact.original_hash.is_some()); + assert!(compact.webxdc_topic.is_some()); + assert!(compact.mls_filename.is_some()); + assert!(compact.scheme_version.is_some()); + + let restored = compact.to_attachment(); + let meta = restored.img_meta.unwrap(); + assert_eq!(meta.thumbhash, "hash123"); + assert_eq!(meta.width, 1920); + assert_eq!(meta.height, 1080); + assert_eq!(restored.webxdc_topic, Some("game-state".into())); + assert_eq!(restored.group_id.unwrap(), att.group_id.unwrap()); + assert_eq!(restored.original_hash.unwrap(), att.original_hash.unwrap()); + assert_eq!(restored.scheme_version, Some("mip04-v1".into())); + assert_eq!(restored.mls_filename, Some("encrypted.bin".into())); + } + + // ======================================================================== + // CompactReaction Tests + // ======================================================================== + + #[test] + fn compact_reaction_from_reaction_roundtrip() { + let reaction = Reaction { + id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(), + author_id: "npub1alice".into(), + emoji: "+".into(), + emoji_url: None, + }; + let mut interner = NpubInterner::new(); + let compact = CompactReaction::from_reaction(&reaction, &mut interner); + let restored = compact.to_reaction(&interner); + + assert_eq!(restored.id, reaction.id, "id mismatch"); + assert_eq!(restored.reference_id, reaction.reference_id, "reference_id mismatch"); + assert_eq!(restored.author_id, reaction.author_id, "author_id mismatch"); + assert_eq!(restored.emoji, reaction.emoji, "emoji mismatch"); + } + + #[test] + fn compact_reaction_author_resolved_via_interner() { + let mut interner = NpubInterner::new(); + let alice_handle = interner.intern("npub1alice"); + + let reaction = Reaction { + id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(), + author_id: "npub1alice".into(), + emoji: "+".into(), + emoji_url: None, + }; + let compact = CompactReaction::from_reaction(&reaction, &mut interner); + assert_eq!(compact.author_idx, alice_handle, "should reuse existing interner handle"); + + let resolved = interner.resolve(compact.author_idx).unwrap(); + assert_eq!(resolved, "npub1alice"); + } + + #[test] + fn compact_reaction_unicode_emoji() { + let reaction = Reaction { + id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(), + author_id: "npub1test".into(), + emoji: "\u{1f431}\u{200d}\u{1f4bb}".into(), // cat with laptop (ZWJ sequence) + emoji_url: None, + }; + let mut interner = NpubInterner::new(); + let compact = CompactReaction::from_reaction(&reaction, &mut interner); + let restored = compact.to_reaction(&interner); + assert_eq!(restored.emoji, "\u{1f431}\u{200d}\u{1f4bb}", "complex unicode emoji should roundtrip"); + } + + #[test] + fn compact_reaction_custom_emoji() { + let reaction = Reaction { + id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(), + author_id: "npub1test".into(), + emoji: ":cat_heart_eyes:".into(), + emoji_url: None, + }; + let mut interner = NpubInterner::new(); + let compact = CompactReaction::from_reaction(&reaction, &mut interner); + let restored = compact.to_reaction(&interner); + assert_eq!(restored.emoji, ":cat_heart_eyes:", "custom emoji shortcode should roundtrip"); + } + + #[test] + fn compact_reaction_owned_conversion() { + let reaction = Reaction { + id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(), + reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(), + author_id: "npub1bob".into(), + emoji: "\u{1f44d}".into(), // thumbs up + emoji_url: None, + }; + let reaction_clone = reaction.clone(); + let mut interner = NpubInterner::new(); + let compact = CompactReaction::from_reaction_owned(reaction, &mut interner); + let restored = compact.to_reaction(&interner); + + assert_eq!(restored.id, reaction_clone.id); + assert_eq!(restored.author_id, reaction_clone.author_id); + assert_eq!(restored.emoji, reaction_clone.emoji); + } + + // ======================================================================== + // TinyVec Tests + // ======================================================================== + + #[test] + fn tinyvec_empty() { + let tv: TinyVec = TinyVec::new(); + assert!(tv.is_empty()); + assert_eq!(tv.len(), 0); + assert_eq!(tv.as_slice(), &[] as &[u32]); + assert_eq!(tv.first(), None); + assert_eq!(tv.last(), None); + } + + #[test] + fn tinyvec_from_vec_and_back() { + let original = vec![1u32, 2, 3, 4, 5]; + let tv = TinyVec::from_vec(original.clone()); + assert_eq!(tv.len(), 5); + assert_eq!(tv.to_vec(), original); + } + + #[test] + fn tinyvec_indexing() { + let tv = TinyVec::from_vec(vec![10u32, 20, 30]); + assert_eq!(tv[0], 10); + assert_eq!(tv[1], 20); + assert_eq!(tv[2], 30); + assert_eq!(tv.get(0), Some(&10)); + assert_eq!(tv.get(3), None); + } + + #[test] + fn tinyvec_push() { + let mut tv = TinyVec::from_vec(vec![1u32, 2]); + tv.push(3); + assert_eq!(tv.len(), 3); + assert_eq!(tv.to_vec(), vec![1, 2, 3]); + } + + #[test] + fn tinyvec_clone() { + let tv = TinyVec::from_vec(vec!["hello".to_string(), "world".to_string()]); + let cloned = tv.clone(); + assert_eq!(cloned.len(), 2); + assert_eq!(cloned[0], "hello"); + assert_eq!(cloned[1], "world"); + } + + #[test] + fn tinyvec_retain() { + let mut tv = TinyVec::from_vec(vec![1u32, 2, 3, 4, 5]); + tv.retain(|&x| x % 2 == 0); + assert_eq!(tv.to_vec(), vec![2, 4]); + } + + #[test] + fn tinyvec_empty_from_empty_vec() { + let tv = TinyVec::::from_vec(vec![]); + assert!(tv.is_empty()); + assert_eq!(tv.len(), 0); + } + + #[test] + fn tinyvec_iter() { + let tv = TinyVec::from_vec(vec![10u32, 20, 30]); + let sum: u32 = tv.iter().sum(); + assert_eq!(sum, 60); + } + + #[test] + fn tinyvec_any() { + let tv = TinyVec::from_vec(vec![1u32, 2, 3]); + assert!(tv.any(|&x| x == 2)); + assert!(!tv.any(|&x| x == 99)); + } + + // ======================================================================== + // hex_to_bytes_16 / bytes_to_hex_string Tests + // ======================================================================== + + #[test] + fn hex_to_bytes_16_full_32_chars() { + let hex = "aabbccddaabbccddaabbccddaabbccdd"; + let bytes = hex_to_bytes_16(hex); + assert_eq!(bytes[0], 0xaa); + assert_eq!(bytes[1], 0xbb); + assert_eq!(bytes[15], 0xdd); + } + + #[test] + fn hex_to_bytes_16_short_input_padded() { + // Short input gets right-padded with '0' before decode + let hex = "aabb"; + let bytes = hex_to_bytes_16(hex); + assert_eq!(bytes[0], 0xaa); + assert_eq!(bytes[1], 0xbb); + // Remaining bytes should be 0 (from padding) + for i in 2..16 { + assert_eq!(bytes[i], 0, "byte {} should be 0 from padding", i); + } + } + + #[test] + fn bytes_to_hex_string_roundtrip() { + let bytes: Vec = vec![0xaa, 0xbb, 0xcc, 0xdd]; + let hex = bytes_to_hex_string(&bytes); + assert_eq!(hex, "aabbccdd"); + } +} diff --git a/crates/vector-core/src/concord/mod.rs b/crates/vector-core/src/concord/mod.rs new file mode 100644 index 00000000..5ab0a239 --- /dev/null +++ b/crates/vector-core/src/concord/mod.rs @@ -0,0 +1,13 @@ +//! Concord — Vector's serverless communities protocol. +//! +//! Versioned side by side: [`v1`] is the original in-house protocol (spec in +//! `docs/concord/`); v2 (the public CORD spec) will live beside it. The +//! communities surface reads from every version but new communities are only +//! created on the newest one. +//! +//! `vector_core::community` is a crate-root alias for [`v1`] so existing +//! consumers (src-tauri, vector-sdk, vector-agent, concord-cli) keep working +//! unchanged. + +pub mod v1; +pub mod v2; diff --git a/crates/vector-core/src/concord/v1/attachments.rs b/crates/vector-core/src/concord/v1/attachments.rs new file mode 100644 index 00000000..7ea259e5 --- /dev/null +++ b/crates/vector-core/src/concord/v1/attachments.rs @@ -0,0 +1,352 @@ +//! Community message attachments (NIP-92 `imeta`). +//! +//! Unlike NIP-17 DMs (one media item per event), a Community message event carries its +//! caption in `content` plus one `imeta` tag per attachment — so a single message can +//! mix text and N files. Each `imeta` holds the per-file AES-GCM key+nonce (the NIP-17 +//! attachment technique: fresh random key per file), so the Blossom ciphertext is only +//! decryptable by members who can open the event. + +use std::path::Path; +use nostr_sdk::prelude::*; +use crate::types::{Attachment, ImageMetadata}; + +const IMETA: &str = "imeta"; + +/// Encode an [`Attachment`] as a NIP-92 `imeta` tag with Vector's encryption fields. +/// Entries are space-delimited `key value` strings (NIP-92 form); a value may contain +/// spaces (e.g. a filename) since only the first space delimits key from value. +pub fn attachment_to_imeta(att: &Attachment) -> Tag { + let mut fields: Vec = Vec::with_capacity(10); + fields.push(format!("url {}", att.url)); + fields.push(format!("m {}", crate::crypto::mime_from_extension(&att.extension))); + fields.push("encryption-algorithm aes-gcm".to_string()); + fields.push(format!("decryption-key {}", att.key)); + fields.push(format!("decryption-nonce {}", att.nonce)); + if att.size > 0 { + fields.push(format!("size {}", att.size)); + } + if let Some(h) = att.original_hash.as_deref().filter(|h| !h.is_empty()) { + fields.push(format!("ox {}", h)); + } + if !att.name.is_empty() { + fields.push(format!("name {}", att.name)); + } + if let Some(meta) = &att.img_meta { + if !meta.thumbhash.is_empty() { + fields.push(format!("thumb {}", meta.thumbhash)); + } + fields.push(format!("dim {}x{}", meta.width, meta.height)); + } + // Mini Apps: the send-time-minted realtime topic rides the imeta so every + // member joins the same gossip topic (see `crate::webxdc::mint_topic_id`). + if let Some(topic) = att.webxdc_topic.as_deref().filter(|t| !t.is_empty()) { + fields.push(format!("webxdc-topic {}", topic)); + } + Tag::custom(TagKind::Custom(IMETA.into()), fields) +} + +/// Read a single `key value` field from an `imeta` tag's entries (value is everything +/// after the first space, so spaces in the value are preserved). +fn field<'a>(entries: &'a [String], key: &str) -> Option<&'a str> { + entries.iter().find_map(|e| { + e.strip_prefix(key) + .and_then(|rest| rest.strip_prefix(' ')) + }) +} + +/// Parse a single `imeta` tag into an [`Attachment`]. `None` if the tag isn't an `imeta` +/// or is missing the required url / decryption fields. `download_dir` computes the +/// (not-yet-downloaded) local target path, mirroring the DM file-attachment path. +pub fn attachment_from_imeta(tag: &Tag, download_dir: &Path) -> Option { + let entries = tag.as_slice(); + if entries.first().map(String::as_str) != Some(IMETA) { + return None; + } + let body = &entries[1..]; + + let url = field(body, "url")?.to_string(); + if url.is_empty() { + return None; + } + let key = field(body, "decryption-key")?.to_string(); + let nonce = field(body, "decryption-nonce")?.to_string(); + + let mime = field(body, "m").unwrap_or("application/octet-stream"); + let name = field(body, "name").map(crate::crypto::sanitize_filename).unwrap_or_default(); + // Prefer the filename's extension (accurate for .toml/.rs/etc. that MIME maps to + // octet-stream); fall back to the MIME-derived extension. + let extension = name + .rsplit('.') + .next() + .filter(|e| !e.is_empty() && *e != name) + .map(|e| e.to_lowercase()) + .unwrap_or_else(|| crate::crypto::extension_from_mime(mime)); + + let size = field(body, "size").and_then(|s| s.parse::().ok()).unwrap_or(0); + let original_hash = field(body, "ox").map(|s| s.to_string()).filter(|s| !s.is_empty()); + + let img_meta = { + let thumb = field(body, "thumb").map(|s| s.to_string()); + let dim = field(body, "dim").and_then(|s| { + let (w, h) = s.split_once('x')?; + Some((w.parse::().ok()?, h.parse::().ok()?)) + }); + match (thumb, dim) { + (Some(thumbhash), Some((width, height))) => Some(ImageMetadata { thumbhash, width, height }), + _ => None, + } + }; + + // Local path keyed on the original hash (dedup across messages) when present, else + // the nonce (unique per send). The basis is author-controlled, so require it to be a + // bounded hex string before joining it into a filesystem path — a hostile member can't + // smuggle `../` traversal into the persisted `path` (defense-in-depth: `open_attachment` + // also re-checks the path is inside the download dir). + let basis = original_hash.clone().unwrap_or_else(|| nonce.clone()); + if basis.is_empty() || basis.len() > 128 || !basis.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let path = download_dir.join(format!("{}.{}", basis, extension)); + let downloaded = path.exists(); + + // Bounded sanity on the author-controlled topic: base32 alphabet only, 32-byte + // payload (52 chars). Anything else is dropped, not propagated to the realtime layer. + let webxdc_topic = field(body, "webxdc-topic") + .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b))) + .map(|t| t.to_string()); + + Some(Attachment { + id: basis, + key, + nonce, + extension, + name, + url, + path: path.to_string_lossy().to_string(), + size, + img_meta, + downloading: false, + downloaded, + webxdc_topic, + group_id: None, // Community attachments use explicit key/nonce (NIP-17 technique). + original_hash, + scheme_version: None, + mls_filename: None, + }) +} + +/// Parse every `imeta` tag on an event into attachments, order preserved. +/// Capped: a max-size event can carry ~1700 imeta tags, each becoming a +/// persisted + in-STATE Attachment — bound the per-message amplification. +pub fn attachments_from_tags<'a>( + tags: impl Iterator, + download_dir: &Path, +) -> Vec { + const MAX_ATTACHMENTS_PER_MESSAGE: usize = 32; + tags.filter_map(|t| attachment_from_imeta(t, download_dir)) + .take(MAX_ATTACHMENTS_PER_MESSAGE) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(name: &str, ext: &str, with_img: bool) -> Attachment { + Attachment { + id: "h".into(), + key: "0".repeat(64), // 32-byte key + nonce: "1".repeat(32), // 16-byte (0xChat-compatible) nonce + extension: ext.into(), + name: name.into(), + url: "https://blossom.example/abc".into(), + path: String::new(), + size: 4096, + img_meta: with_img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 800, height: 600 }), + downloading: false, + downloaded: false, + webxdc_topic: None, + group_id: None, + original_hash: Some("a".repeat(64)), + scheme_version: None, + mls_filename: None, + } + } + + #[test] + fn imeta_round_trip_preserves_crypto_and_meta() { + let dir = std::env::temp_dir(); + let att = sample("my report.png", "png", true); + let tag = attachment_to_imeta(&att); + let back = attachment_from_imeta(&tag, &dir).expect("parses"); + assert_eq!(back.url, att.url); + assert_eq!(back.key, att.key); + assert_eq!(back.nonce, att.nonce); + assert_eq!(back.size, att.size); + assert_eq!(back.original_hash, att.original_hash); + assert_eq!(back.name, "my report.png"); // space in filename survives + assert_eq!(back.extension, "png"); + assert_eq!(back.group_id, None); + let m = back.img_meta.expect("img meta"); + assert_eq!((m.width, m.height), (800, 600)); + assert_eq!(m.thumbhash, "TH"); + } + + #[test] + fn spoiler_and_renamed_filenames_survive_imeta() { + // Spoiler is detected receiver-side by a `SPOILER_` prefix on the attachment NAME, + // so the name (incl. that prefix, and spaces) must round-trip through imeta intact — + // this is what gives Community attachments spoiler/rename parity with DMs. + let dir = std::env::temp_dir(); + let spoiler = attachment_from_imeta(&attachment_to_imeta(&sample("SPOILER_big reveal.png", "png", true)), &dir).unwrap(); + assert_eq!(spoiler.name, "SPOILER_big reveal.png"); + assert!(spoiler.name.to_uppercase().starts_with("SPOILER_"), "spoiler prefix preserved"); + assert_eq!(spoiler.extension, "png"); + + let renamed = attachment_from_imeta(&attachment_to_imeta(&sample("Quarterly Report (final).pdf", "pdf", false)), &dir).unwrap(); + assert_eq!(renamed.name, "Quarterly Report (final).pdf"); + assert_eq!(renamed.extension, "pdf"); + } + + #[test] + fn field_key_match_requires_a_following_space_no_prefix_bleed() { + // `field(_, "m")` must NOT match a longer key like "mime ..." (shared prefix). The + // "key + ' '" requirement guards this; lock it so future imeta fields can't collide. + let entries = vec!["mime image/png".to_string(), "m image/jpeg".to_string()]; + assert_eq!(field(&entries, "m"), Some("image/jpeg")); + assert_eq!(field(&entries, "mime"), Some("image/png")); + assert_eq!(field(&["decryption-key-x abc".to_string()], "decryption-key"), None); + // A key present with no value (no following space) yields None, not a panic. + assert_eq!(field(&["url".to_string()], "url"), None); + } + + #[test] + fn multiple_imeta_tags_parse_in_order() { + let dir = std::env::temp_dir(); + let tags = vec![ + Tag::custom(TagKind::Custom("z".into()), ["pseudonym"]), + attachment_to_imeta(&sample("a.png", "png", false)), + Tag::custom(TagKind::Custom("ms".into()), ["12"]), + attachment_to_imeta(&sample("b.pdf", "pdf", false)), + ]; + let atts = attachments_from_tags(tags.iter(), &dir); + assert_eq!(atts.len(), 2); + assert_eq!(atts[0].name, "a.png"); + assert_eq!(atts[1].name, "b.pdf"); + assert_eq!(atts[1].extension, "pdf"); + } + + #[test] + fn non_imeta_and_incomplete_tags_are_skipped() { + let dir = std::env::temp_dir(); + let not_imeta = Tag::custom(TagKind::Custom("e".into()), ["abc"]); + assert!(attachment_from_imeta(¬_imeta, &dir).is_none()); + // imeta missing decryption fields → None. + let bad = Tag::custom(TagKind::Custom("imeta".into()), ["url https://x/y"]); + assert!(attachment_from_imeta(&bad, &dir).is_none()); + } + + #[test] + fn imeta_crypto_params_actually_decrypt_the_ciphertext() { + // End-to-end attachment crypto: encrypt a plaintext with the real params, carry the + // key/nonce via imeta, parse them back out, and confirm they decrypt the ciphertext. + // This is the receiver's download path in miniature (minus the Blossom fetch). + let dir = std::env::temp_dir(); + let plaintext = b"the quick brown fox jumps over 13 lazy dogs".to_vec(); + let params = crate::crypto::generate_encryption_params(); + let ciphertext = crate::crypto::encrypt_data(&plaintext, ¶ms).unwrap(); + + let att = Attachment { + id: "x".into(), + key: params.key.clone(), + nonce: params.nonce.clone(), + extension: "txt".into(), + name: "note.txt".into(), + url: "https://blossom.example/blob".into(), + path: String::new(), + size: ciphertext.len() as u64, + img_meta: None, + downloading: false, + downloaded: false, + webxdc_topic: None, + group_id: None, + original_hash: Some("c".repeat(64)), + scheme_version: None, + mls_filename: None, + }; + let parsed = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses"); + // The parsed key/nonce (straight off the imeta) must decrypt the ciphertext. + let decrypted = crate::crypto::decrypt_data(&ciphertext, &parsed.key, &parsed.nonce) + .expect("decrypts with imeta-carried params"); + assert_eq!(decrypted, plaintext, "round-trip plaintext matches"); + } + + #[test] + fn hostile_path_basis_is_rejected() { + // A channel member authors the imeta, so the path basis (`ox`, else `nonce`) is + // attacker-controlled. A non-hex / traversal basis must be refused, never joined + // into a filesystem path. + let dir = std::path::Path::new("/tmp/vector-test-dl"); + let traversal = Tag::custom(TagKind::Custom("imeta".into()), [ + "url https://x/y", + "decryption-key 00", + "decryption-nonce 11", + "ox ../../../../etc/passwd", + ]); + assert!(attachment_from_imeta(&traversal, dir).is_none(), "traversal ox rejected"); + + // Falls back to nonce when ox absent — a non-hex nonce is likewise rejected. + let bad_nonce = Tag::custom(TagKind::Custom("imeta".into()), [ + "url https://x/y", + "decryption-key 00", + "decryption-nonce ../evil", + ]); + assert!(attachment_from_imeta(&bad_nonce, dir).is_none(), "traversal nonce rejected"); + + // A legitimate hex basis still parses. + let good = Tag::custom(TagKind::Custom("imeta".into()), [ + "url https://x/y".to_string(), + "decryption-key 00".to_string(), + "decryption-nonce 11".to_string(), + format!("ox {}", "a".repeat(64)), + ]); + assert!(attachment_from_imeta(&good, dir).is_some(), "hex ox accepted"); + } + + #[test] + fn webxdc_topic_round_trips_imeta_and_garbage_is_dropped() { + let dir = std::env::temp_dir(); + let topic = crate::webxdc::mint_topic_id("hash", "sender"); + let mut att = sample("game.xdc", "xdc", false); + att.webxdc_topic = Some(topic.clone()); + let back = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses"); + assert_eq!(back.webxdc_topic.as_deref(), Some(topic.as_str())); + + // Author-controlled: wrong-length / off-alphabet topics are dropped, not propagated. + for bad in ["short", &"A".repeat(53), &"a".repeat(52), &format!("{}!", "A".repeat(51))] { + let mut att = sample("game.xdc", "xdc", false); + att.webxdc_topic = Some(bad.to_string()); + let back = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses"); + assert_eq!(back.webxdc_topic, None, "bad topic {:?} must be dropped", bad); + } + } + + #[test] + fn malformed_imeta_does_not_panic_and_drops_gracefully() { + let dir = std::env::temp_dir(); + // Garbage entries, duplicate keys, value-less keys, weird spacing — must not panic. + let junk = Tag::custom(TagKind::Custom("imeta".into()), [ + "url", // no value + "decryption-key", // no value + "random noise here", + " ", + "url https://x/legit", // a later valid url + ]); + // Missing decryption-key/nonce → None (not a panic). + assert!(attachment_from_imeta(&junk, &dir).is_none()); + + // Empty imeta (just the tag name) → None. + let empty = Tag::custom(TagKind::Custom("imeta".into()), Vec::::new()); + assert!(attachment_from_imeta(&empty, &dir).is_none()); + } +} diff --git a/crates/vector-core/src/concord/v1/cache.rs b/crates/vector-core/src/concord/v1/cache.rs new file mode 100644 index 00000000..f1987e4b --- /dev/null +++ b/crates/vector-core/src/concord/v1/cache.rs @@ -0,0 +1,255 @@ +//! Per-account RAM cache for Community sync state. +//! +//! Consolidates what were three scattered `LazyLock` statics (oldest-page cursor, history-start +//! floors, in-flight page de-dup) into ONE structure under ONE invalidation key: the session +//! generation. Any access after an account swap — which bumps `current_session_generation()` — +//! transparently resets the cache, so stale per-channel state can never bleed into the next +//! account. Holds the page cursors (oldest back-paging floor + newest `since` floor), history-start +//! flags, and in-flight page de-dup; future RAM-cache work (e.g. invite preload) layers onto the +//! same structure + invalidation discipline. + +use nostr_sdk::Event; +use std::collections::{HashMap, HashSet}; +use std::sync::{LazyLock, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +#[derive(Default)] +struct CommunityCache { + /// Session generation this cache reflects; a mismatch on access means an account swap + /// happened → the cache is reset before use. + generation: u64, + /// In-flight page fetches, keyed `"{channel_id}:{older|latest}"`. Anti-stampede: an eager + /// user scrolling/clicking can't fire the same page twice — the duplicate no-ops. + inflight: HashSet, + /// Channels whose network history-start has been reached (an older-page fetch found nothing + /// strictly older than the cursor). Older-page requests for these go DB-only. + history_start: HashSet, + /// Oldest OUTER (wire send-time) created_at, in seconds, fetched per channel. The relay + /// filters `until` against the outer created_at, so the back-paging cursor MUST be on that + /// clock — not the inner authored `at`, which a hostile member can backdate/post-date. + oldest_cursor: HashMap, + /// Newest OUTER created_at (seconds) seen on a LATEST-page fetch per channel. Used as `since` + /// on the next latest fetch so a routine re-sync returns only genuinely-new events instead of + /// re-downloading + re-decrypting the same newest page. Advanced ONLY by latest fetches (never + /// older pages) — it means "nothing newer than this needs a top-fetch"; any below-page gap is + /// a back-pagination concern, not a top-fetch one. + newest_cursor: HashMap, +} + +static CACHE: LazyLock> = LazyLock::new(|| Mutex::new(CommunityCache::default())); + +/// Lock the cache, transparently resetting it if the session generation advanced (account swap). +fn locked() -> MutexGuard<'static, CommunityCache> { + let generation = crate::state::current_session_generation(); + // Poison-tolerant: this cache is pure optimization state, so recover a poisoned guard rather + // than cascade-panic every future community sync. + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if cache.generation != generation { + *cache = CommunityCache { generation, ..Default::default() }; + } + cache +} + +/// Claim an in-flight page fetch (key `"{channel_id}:{older|latest}"`). Returns `false` if one is +/// already running — the caller should no-op. Pair with [`end_page_fetch`]. +pub fn try_begin_page_fetch(key: &str) -> bool { + locked().inflight.insert(key.to_string()) +} + +/// Release an in-flight page-fetch claim (success or error). +pub fn end_page_fetch(key: &str) { + locked().inflight.remove(key); +} + +/// Has the channel's network history-start been reached? Older pages then stay DB-only. +pub fn is_at_history_start(channel_id: &str) -> bool { + locked().history_start.contains(channel_id) +} + +/// Mark the channel as having reached its network history-start. +pub fn mark_history_start(channel_id: &str) { + locked().history_start.insert(channel_id.to_string()); +} + +/// Oldest OUTER created_at (seconds) fetched for the channel — the back-paging cursor. +pub fn oldest_cursor(channel_id: &str) -> Option { + locked().oldest_cursor.get(channel_id).copied() +} + +/// Advance the back-paging cursor to the oldest wire time this page returned (monotonic — only +/// ever steps further back). +pub fn advance_oldest_cursor(channel_id: &str, oldest_secs: u64) { + let mut cache = locked(); + let slot = cache.oldest_cursor.entry(channel_id.to_string()).or_insert(oldest_secs); + *slot = (*slot).min(oldest_secs); +} + +/// Newest OUTER created_at (seconds) seen on a latest page for the channel — the `since` floor +/// for the next latest fetch. `None` before the first latest fetch this session (→ full newest page). +pub fn newest_cursor(channel_id: &str) -> Option { + locked().newest_cursor.get(channel_id).copied() +} + +/// Advance the latest-page `since` floor to the newest wire time this page returned (monotonic — +/// only ever steps forward). Call ONLY for latest-page fetches. +pub fn advance_newest_cursor(channel_id: &str, newest_secs: u64) { + let mut cache = locked(); + let slot = cache.newest_cursor.entry(channel_id.to_string()).or_insert(newest_secs); + *slot = (*slot).max(newest_secs); +} + +/// Clear a channel's back-paging floors (history-start + oldest cursor) — e.g. after a +/// multi-epoch backfill makes older history reachable again. +pub fn clear_channel_floors(channel_id: &str) { + let mut cache = locked(); + cache.history_start.remove(channel_id); + cache.oldest_cursor.remove(channel_id); +} + +/// Drop ALL of a channel's sync state (floors + the latest-page `since` cursor) — community +/// teardown. A surviving `since` cursor makes a same-session REJOIN sync "since I left" +/// instead of cold, so the rejoined chat opens empty despite plenty of history. +pub fn clear_channel_sync_state(channel_id: &str) { + let mut cache = locked(); + cache.history_start.remove(channel_id); + cache.oldest_cursor.remove(channel_id); + cache.newest_cursor.remove(channel_id); +} + +// ── Invite preload ────────────────────────────────────────────────────────── +// Warmed-ahead-of-Join state: the primary channel's first page, fetched at invite-receive / +// public-preview time so a Join can open to a populated chat instead of a ~10s sync. RAM-only — +// nothing is persisted for a community the user hasn't joined, so a declined invite leaves no DB +// trace. Generation-stamped (cleared on account swap) + TTL'd + capped. + +/// How long a warmed page stays promotable. Past this, Join falls back to a normal sync. +pub(crate) const PRELOAD_TTL: Duration = Duration::from_secs(120); +/// Max communities warmed at once (bounds memory; oldest evicted on overflow). +const PRELOAD_MAX: usize = 8; + +/// How long the sync will adopt an in-flight (Pending) preload before giving up and fetching itself. +/// Generous: the preload fetch is itself relay-racing, so adopting it is never slower than firing a +/// parallel fetch — and a failed preload aborts (→ absent) so the sync falls back immediately, not +/// at the deadline. +const PRELOAD_ADOPT_TIMEOUT: Duration = Duration::from_secs(12); + +enum PreloadState { + /// A warm-up fetch is in flight. A Join can ADOPT it (await this result) instead of firing its + /// own — so the speedup holds even when the user taps Join before the warm-up finished. + Pending, + /// The warmed page is ready to promote/adopt. + Ready(Vec), +} + +struct Preload { + state: PreloadState, + fetched_at: Instant, + generation: u64, +} + +static PRELOAD: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn preload_locked() -> MutexGuard<'static, HashMap> { + PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Mark a community's warm-up as in-flight (so a racing Join adopts it rather than double-fetching). +/// Evicts stale/cross-generation entries and, if over the cap, the oldest. +pub fn begin_preload(community_id: &str) { + let generation = crate::state::current_session_generation(); + let mut map = preload_locked(); + map.retain(|_, p| p.generation == generation && p.fetched_at.elapsed() < PRELOAD_TTL); + if map.len() >= PRELOAD_MAX { + if let Some(oldest) = map.iter().min_by_key(|(_, p)| p.fetched_at).map(|(k, _)| k.clone()) { + map.remove(&oldest); + } + } + map.insert( + community_id.to_string(), + Preload { state: PreloadState::Pending, fetched_at: Instant::now(), generation }, + ); +} + +/// The warm-up fetch landed — make its page available to promote/adopt. +pub fn finish_preload(community_id: &str, page: Vec) { + let generation = crate::state::current_session_generation(); + let mut map = preload_locked(); + // A warm-up begun under a previous session must not be re-stamped into this one — + // drop it instead (the new account fetches its own page if it ever joins). + let stale = match map.get_mut(community_id) { + Some(p) if p.generation == generation => { + p.state = PreloadState::Ready(page); + p.fetched_at = Instant::now(); + false + } + Some(_) => true, + None => false, + }; + if stale { + map.remove(community_id); + } +} + +/// The warm-up fetch failed/was cancelled — drop the entry so an adopter falls back immediately. +pub fn abort_preload(community_id: &str) { + preload_locked().remove(community_id); +} + +/// Non-blocking take for promotion at Accept: returns the page ONLY if already Ready, leaving a +/// still-Pending warm-up in place for the sync to adopt. `None` if absent / Pending / stale. +pub fn take_ready_preload(community_id: &str) -> Option> { + let generation = crate::state::current_session_generation(); + let mut map = preload_locked(); + let fresh = matches!(map.get(community_id), Some(p) + if p.generation == generation + && p.fetched_at.elapsed() < PRELOAD_TTL + && matches!(p.state, PreloadState::Ready(_))); + if !fresh { + return None; + } + match map.remove(community_id) { + Some(Preload { state: PreloadState::Ready(page), .. }) => Some(page), + _ => None, + } +} + +/// Adopt a community's warm-up as this sync's page: Ready → take it; Pending → await it (the +/// in-flight fetch IS the page, so this waits only the request's remaining time, never firing a +/// second); absent/stale/failed → `None` so the caller fetches normally. Polls at coarse granularity +/// (imperceptible vs. a fresh round-trip) to stay free of notification races. +pub async fn take_or_await_preload(community_id: &str) -> Option> { + let deadline = Instant::now() + PRELOAD_ADOPT_TIMEOUT; + loop { + { + let generation = crate::state::current_session_generation(); + let mut map = preload_locked(); + match map.get(community_id) { + Some(p) if p.generation == generation && p.fetched_at.elapsed() < PRELOAD_TTL => { + if matches!(p.state, PreloadState::Ready(_)) { + return match map.remove(community_id) { + Some(Preload { state: PreloadState::Ready(page), .. }) => Some(page), + _ => None, + }; + } + // Pending → keep waiting. + } + _ => return None, // absent / stale / aborted → fetch normally + } + } + if Instant::now() >= deadline { + return None; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// Drop all cached state (account swap / reset). Access-time generation checks self-reset too, +/// so this is an explicit belt-and-suspenders teardown. +pub fn clear() { + *PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = HashMap::new(); + *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = CommunityCache { + generation: crate::state::current_session_generation(), + ..Default::default() + }; +} diff --git a/crates/vector-core/src/concord/v1/cipher.rs b/crates/vector-core/src/concord/v1/cipher.rs new file mode 100644 index 00000000..d413bbae --- /dev/null +++ b/crates/vector-core/src/concord/v1/cipher.rs @@ -0,0 +1,47 @@ +//! Raw-key NIP-44 v2 sealing — the single symmetric-encryption primitive of the +//! Community protocol. The channel key (message plane) and the server-root key +//! (metadata plane) are both raw 32-byte `ConversationKey`s; ciphertext is +//! base64'd for carriage in an event's string `content` field. + +use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey}; + +/// Encrypt `plaintext` under a raw 32-byte key, returning base64 for event content. +pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Result { + let ck = ConversationKey::new(*key); + let ciphertext = encrypt_to_bytes(&ck, plaintext).map_err(|e| e.to_string())?; + Ok(base64_simd::STANDARD.encode_to_string(&ciphertext)) +} + +/// Inverse of [`seal`]: base64-decode then NIP-44-decrypt under the raw key. A +/// wrong key or tampered payload fails the MAC and returns `Err`. +pub fn open(key: &[u8; 32], content_b64: &str) -> Result, String> { + let ciphertext = base64_simd::STANDARD + .decode_to_vec(content_b64.as_bytes()) + .map_err(|e| e.to_string())?; + let ck = ConversationKey::new(*key); + decrypt_to_bytes(&ck, &ciphertext).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let key = [0x5au8; 32]; + let sealed = seal(&key, b"hello community").unwrap(); + assert_eq!(open(&key, &sealed).unwrap(), b"hello community"); + } + + #[test] + fn wrong_key_fails() { + let sealed = seal(&[1u8; 32], b"secret").unwrap(); + assert!(open(&[2u8; 32], &sealed).is_err()); + } + + #[test] + fn distinct_ciphertext_per_call() { + let key = [9u8; 32]; + assert_ne!(seal(&key, b"x").unwrap(), seal(&key, b"x").unwrap()); + } +} diff --git a/crates/vector-core/src/concord/v1/db.rs b/crates/vector-core/src/concord/v1/db.rs new file mode 100644 index 00000000..2b4bf642 --- /dev/null +++ b/crates/vector-core/src/concord/v1/db.rs @@ -0,0 +1,2254 @@ +//! Persistence for Community protocol local state (GROUP_PROTOCOL.md). +//! +//! Stores the secrets this account *holds*: the server-root key and per-channel keys +//! (epoch-tagged). The DB is already account-scoped (`account_dir(npub)/vector.db`), so +//! there is no npub column — a row belongs to whichever account's DB it lives in. +//! +//! At-rest encryption: when Local Encryption is on, every secret BLOB and every identifying +//! metadata field (names, relays, roles, banlist, owner attestation, invite material) is wrapped +//! with the account's ENCRYPTION_KEY before it touches disk and unwrapped on read, via the +//! `enc_*`/`dec_*` helpers below. A raw DB then reveals no WHO/WHERE/WHAT. The discriminators +//! (32-byte raw key vs 60-byte ciphertext; `looks_encrypted` for text) let a half-migrated DB +//! read back correctly, so the toggle/PIN-rekey flows and the one-time backfill are safe to re-run. + +use nostr_sdk::prelude::{Keys, PublicKey, SecretKey}; +use nostr_sdk::ToBech32; +use rusqlite::{params, OptionalExtension}; + +use crate::community::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey}; + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn to_32(bytes: &[u8]) -> Result<[u8; 32], String> { + bytes + .try_into() + .map_err(|_| format!("expected 32-byte key, got {} bytes", bytes.len())) +} + +// At-rest wrappers (see module doc). `enc_*` for write binds, `dec_*` for read. +fn enc_key(k: &[u8; 32]) -> Result, String> { crate::crypto::maybe_encrypt_blob(k) } +fn dec_key(stored: &[u8]) -> Result<[u8; 32], String> { to_32(&crate::crypto::maybe_decrypt_blob(stored)) } +fn enc_txt(s: &str) -> Result { crate::crypto::maybe_encrypt_text(s) } +fn dec_txt(s: &str) -> String { crate::crypto::maybe_decrypt_text(s) } +/// Encrypt an optional text field, preserving NULL. +fn enc_txt_opt(s: &Option) -> Result, String> { + s.as_deref().map(enc_txt).transpose() +} + +/// Decode a 64-char hex id to 32 bytes, REJECTING malformed input. Unlike +/// `simd::hex::hex_to_bytes_32`, this never silently zero-fills or truncates — a +/// corrupted id row must error, not reconstruct a wrong-but-self-consistent id. +pub(crate) fn hex_id_to_32(hex: &str) -> Result<[u8; 32], String> { + crate::simd::hex::hex_to_bytes_32_checked(hex) + .ok_or_else(|| format!("corrupt or wrong-length 64-char hex id ({} chars)", hex.len())) +} + +/// Persist a Community and all its channels (upsert). Secrets are stored as raw +/// blobs in the account-scoped DB. +pub fn save_community(community: &Community) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?; + let community_id = community.id.to_hex(); + + // icon/banner persisted as the CommunityImage JSON ref (None → NULL). + let icon_json = community + .icon + .as_ref() + .map(|i| serde_json::to_string(i)) + .transpose() + .map_err(|e| e.to_string())?; + let banner_json = community + .banner + .as_ref() + .map(|b| serde_json::to_string(b)) + .transpose() + .map_err(|e| e.to_string())?; + // Atomic: the community row + all its channel rows commit together, so a crash mid-save + // can't leave a Community with a partial channel set. + let tx = conn.unchecked_transaction().map_err(|e| format!("save community tx: {e}"))?; + // UPSERT (not INSERT OR REPLACE): a metadata re-save must NOT reset `banlist` (managed + // separately via set_community_banlist) or `created_at` to their defaults — REPLACE deletes + // the row first, so omitted columns would revert. + // Wrap secrets + identifying metadata before they touch disk (no-op when encryption is off). + let enc_root = enc_key(community.server_root_key.as_bytes())?; + let enc_name = enc_txt(&community.name)?; + let enc_relays = enc_txt(&relays_json)?; + let enc_desc = enc_txt_opt(&community.description)?; + let enc_icon = enc_txt_opt(&icon_json)?; + let enc_banner = enc_txt_opt(&banner_json)?; + let enc_owner = enc_txt_opt(&community.owner_attestation)?; + tx.execute( + "INSERT INTO communities + (community_id, server_root_key, name, relays, created_at, + description, icon, banner, owner_attestation, server_root_epoch) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(community_id) DO UPDATE SET + server_root_key=excluded.server_root_key, name=excluded.name, relays=excluded.relays, + description=excluded.description, icon=excluded.icon, banner=excluded.banner, + owner_attestation=excluded.owner_attestation, server_root_epoch=excluded.server_root_epoch", + params![ + community_id, + &enc_root[..], + enc_name, + enc_relays, + now_secs(), + enc_desc, + enc_icon, + enc_banner, + enc_owner, + community.server_root_epoch.0 as i64, + ], + ) + .map_err(|e| format!("save community: {e}"))?; + + // Archive the base key at its epoch so a future rotation can't clobber it (multi-held keys). + store_epoch_key_tx(&tx, &community_id, crate::community::SERVER_ROOT_SCOPE_HEX, + community.server_root_epoch.0, community.server_root_key.as_bytes())?; + + for channel in &community.channels { + let enc_chan_key = enc_key(channel.key.as_bytes())?; + let enc_chan_name = enc_txt(&channel.name)?; + // UPSERT (not INSERT OR REPLACE): a re-save must preserve `created_at` (channel ordering) and + // `rekeyed_at_server_epoch` (read-cut resume progress) — REPLACE would reset both to defaults. + tx.execute( + "INSERT INTO community_channels + (channel_id, community_id, channel_key, epoch, name, created_at, rekeyed_at_server_epoch) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(channel_id) DO UPDATE SET + community_id=excluded.community_id, channel_key=excluded.channel_key, + epoch=excluded.epoch, name=excluded.name", + params![ + channel.id.to_hex(), + community_id, + &enc_chan_key[..], + // SQLite INTEGER is i64; reinterpret the u64 epoch bit-for-bit + // (lossless two's-complement). NOTE: a signed SQL comparison would + // mis-order epochs >= 2^63 — don't ORDER BY / range-filter `epoch`. + channel.epoch.0 as i64, + enc_chan_name, + now_secs(), + // A newly-inserted channel is current as of the community's base epoch (no cut owed for it). + community.server_root_epoch.0 as i64, + ], + ) + .map_err(|e| format!("save channel: {e}"))?; + // Mirror the channel's current-epoch key into the multi-held archive. The + // `community_channels` row above is just the head pointer (REPLACE clobbers it); the archive + // (PK includes epoch) retains EVERY epoch key so cross-epoch history stays readable post-rekey. + store_epoch_key_tx(&tx, &community_id, &channel.id.to_hex(), channel.epoch.0, channel.key.as_bytes())?; + } + tx.commit().map_err(|e| format!("save community commit: {e}"))?; + Ok(()) +} + +/// Store one held epoch key in the multi-held archive. `scope_id` is a channel_id hex or +/// [`crate::community::SERVER_ROOT_SCOPE_HEX`]. The `(community, scope, epoch)` PK makes a write for +/// one epoch unable to disturb another epoch's key — so retained history survives a rekey. Uses +/// REPLACE on the exact coordinate so the fork-resolution apply path can commit the *winning* +/// key for a contested epoch over a previously-stored loser (the only legitimate same-coordinate +/// overwrite; an epoch key is otherwise immutable). +pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + store_epoch_key_tx(&conn, community_id, scope_id, epoch, key) +} + +/// Shared INSERT body so `save_community` can archive keys inside its own transaction and the +/// standalone [`store_epoch_key`] can run on a borrowed connection. `C: Deref` +/// covers both a `Connection` and a `Transaction`. +fn store_epoch_key_tx>( + conn: &C, + community_id: &str, + scope_id: &str, + epoch: u64, + key: &[u8; 32], +) -> Result<(), String> { + let enc = enc_key(key)?; + conn.execute( + "INSERT OR REPLACE INTO community_epoch_keys + (community_id, scope_id, epoch, key, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + // epoch reinterpreted u64->i64 (lossless); never ORDER BY / range-filter it in SQL (see save). + params![community_id, scope_id, epoch as i64, &enc[..], now_secs()], + ) + .map_err(|e| format!("store epoch key: {e}"))?; + Ok(()) +} + +/// Apply a received channel rekey's new key — the atomic archive+head dual-write: in ONE transaction, +/// ARCHIVE `(channel, new_epoch) -> new_key` in `community_epoch_keys` AND advance the channel's +/// read-head (`community_channels.epoch` + `channel_key`) iff `new_epoch` exceeds the current head. +/// A caught-up OLDER epoch is archived (its history stays decryptable) but never regresses the head. +/// Atomic so a crash can't leave the archive ahead of the head or the reverse. Returns whether the +/// head advanced. Epoch comparison is done in RUST (the u64-as-i64 ≥2^63 SQL mis-order trap). +pub fn advance_channel_epoch( + community_id: &str, + channel_id: &str, + new_epoch: u64, + new_key: &[u8; 32], +) -> Result { + let conn = crate::db::get_write_connection_guard_static()?; + let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?; + // Archive always (PK includes epoch → never clobbers another epoch's key). + store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?; + // Monotonic head advance, compared in Rust. + let cur: Option = tx + .query_row( + "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2", + params![community_id, channel_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("read channel head: {e}"))?; + let advanced = matches!(cur, Some(c) if new_epoch > c as u64); + if advanced { + let enc = enc_key(new_key)?; + tx.execute( + "UPDATE community_channels SET epoch = ?1, channel_key = ?2 + WHERE community_id = ?3 AND channel_id = ?4", + params![new_epoch as i64, &enc[..], community_id, channel_id], + ) + .map_err(|e| format!("advance channel head: {e}"))?; + } + tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?; + Ok(advanced) +} + +/// Apply a received SERVER-ROOT (base) rekey's new root — the base counterpart to +/// [`advance_channel_epoch`], atomic: in ONE transaction, ARCHIVE `(server-root scope, new_epoch) -> +/// new_root` in `community_epoch_keys` AND advance the base head (`communities.server_root_epoch` + +/// `server_root_key`) iff `new_epoch` exceeds the current base epoch (monotonic, compared in RUST). A +/// caught-up OLDER base epoch is archived (its control/base history stays decryptable) but never +/// regresses the head. Returns whether the head advanced. +pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result { + let conn = crate::db::get_write_connection_guard_static()?; + let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?; + // Archive always, under the all-zero server-root scope sentinel (PK includes epoch → never clobbers + // another epoch's root). + store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?; + let cur: Option = tx + .query_row( + "SELECT server_root_epoch FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("read server-root epoch: {e}"))?; + let advanced = matches!(cur, Some(c) if new_epoch > c as u64); + if advanced { + let enc = enc_key(new_root)?; + tx.execute( + "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3", + params![new_epoch as i64, &enc[..], community_id], + ) + .map_err(|e| format!("advance server-root head: {e}"))?; + } + tx.commit().map_err(|e| format!("advance server root commit: {e}"))?; + Ok(advanced) +} + +/// SAME-EPOCH convergence for the server root (concurrent re-founding heal): two BAN-holders who +/// re-founded at the same time each sit on their OWN root at the SAME epoch. `advance_server_root_epoch` +/// refuses to switch (its guard is strictly monotonic), so this is the sibling that REPLACES the head root +/// at `epoch` with the deterministic winner (lowest root bytes — the caller decides). Archives the new root +/// + swaps the head, but ONLY while we're still AT `epoch` (a later real rotation must win over a stale +/// converge). Returns whether it switched. +pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result { + let conn = crate::db::get_write_connection_guard_static()?; + let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?; + store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?; + let enc = enc_key(new_root)?; + let switched = tx + .execute( + "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3", + params![&enc[..], community_id, epoch as i64], + ) + .map_err(|e| format!("converge server-root head: {e}"))? + > 0; + tx.commit().map_err(|e| format!("converge server root commit: {e}"))?; + Ok(switched) +} + +/// SAME-EPOCH convergence for a channel key (concurrent re-founding heal) — the channel counterpart to +/// [`converge_server_root_epoch`]. Adopts the winning re-founding's channel key at `epoch` (the rekey +/// addressed under the converged server root), replacing the one we minted in our own losing fork. Switches +/// only while the channel is still AT `epoch`. Returns whether it switched. +pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result { + let conn = crate::db::get_write_connection_guard_static()?; + let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?; + store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?; + let enc = enc_key(new_key)?; + let switched = tx + .execute( + "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4", + params![&enc[..], community_id, channel_id, epoch as i64], + ) + .map_err(|e| format!("converge channel head: {e}"))? + > 0; + tx.commit().map_err(|e| format!("converge channel commit: {e}"))?; + Ok(switched) +} + +/// Every held `(epoch, key)` for a scope, ascending by epoch. The read paths derive a pseudonym per +/// returned epoch (`#z` OR-set) so cross-epoch history isn't stranded. Sorted in Rust (not SQL): +/// epoch is a u64 stored as i64, so a SQL `ORDER BY` would mis-order epochs >= 2^63. +pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id, scope_id], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, Vec>(1)?)) + }) + .map_err(|e| e.to_string())?; + let mut out: Vec<(Epoch, [u8; 32])> = Vec::new(); + for row in rows { + let (epoch, key_blob) = row.map_err(|e| e.to_string())?; + out.push((Epoch(epoch as u64), dec_key(&key_blob)?)); + } + out.sort_by_key(|(e, _)| e.0); + Ok(out) +} + +/// The held key for one specific `(scope, epoch)`, or `None` if not held. The open path uses this to +/// select the decryption key by the inbound event's `epoch` tag. +pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let blob: Option> = conn + .query_row( + "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3", + params![community_id, scope_id, epoch as i64], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("held epoch key: {e}"))?; + blob.map(|b| dec_key(&b)).transpose() +} + +/// Local first-save time of a community (≈ when this account joined or created it), in ms. +/// `created_at` is set on the first save and preserved across metadata re-saves, so it tracks +/// the join moment. Used to sort a not-yet-active community by join time. `None` if unknown. +pub fn community_created_at_ms(id: &CommunityId) -> Option { + let conn = crate::db::get_db_connection_guard_static().ok()?; + conn.query_row( + "SELECT created_at FROM communities WHERE community_id = ?1", + params![id.to_hex()], + |r| r.get::<_, i64>(0), + ) + .optional() + .ok() + .flatten() + .map(|secs| (secs.max(0) as u64) * 1000) +} + +/// Load a Community and its channels by id. Returns `None` if not stored locally. +pub fn load_community(id: &CommunityId) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let id_hex = id.to_hex(); + + let row = conn + .query_row( + "SELECT server_root_key, name, relays, + description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved + FROM communities WHERE community_id = ?1", + params![id_hex], + |r| { + Ok(( + r.get::<_, Vec>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, String>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, i64>(8)?, + r.get::<_, i64>(9)?, + )) + }, + ) + .optional() + .map_err(|e| format!("load community: {e}"))?; + + let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) = + match row { + Some(t) => t, + None => return Ok(None), + }; + let dissolved = dissolved_int != 0; + + // Unwrap at-rest encryption before parsing (no-op when off, or for not-yet-wrapped rows). + let name = dec_txt(&name); + let relays_json = dec_txt(&relays_json); + let description = description.map(|s| dec_txt(&s)); + let icon_json = icon_json.map(|s| dec_txt(&s)); + let banner_json = banner_json.map(|s| dec_txt(&s)); + let banlist_json = dec_txt(&banlist_json); + let owner_attestation = owner_attestation.map(|s| dec_txt(&s)); + + // Banlist: stored as a JSON array of hex pubkeys; parse to PublicKeys (skipping any + // malformed entry) and denormalize onto every channel so the inbound path can drop + // banned authors. A bad/empty column degrades to "no bans", never an error. + let banned: Vec = serde_json::from_str::>(&banlist_json) + .unwrap_or_default() + .iter() + .filter_map(|h| PublicKey::from_hex(h).ok()) + .collect(); + + let icon = icon_json + .map(|j| serde_json::from_str(&j)) + .transpose() + .map_err(|e| format!("icon json: {e}"))?; + let banner = banner_json + .map(|j| serde_json::from_str(&j)) + .transpose() + .map_err(|e| format!("banner json: {e}"))?; + + let server_root_key = ServerRootKey(dec_key(&root_blob)?); + let relays: Vec = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?; + + // hierarchy invariant (apply-time): the OWNER is the uppermost role and can never be + // effectively banned or hidden — by anyone. Everyone knows the owner from the attestation, so + // ALL members enforce this (the owner is filtered out of `banned` and protected from hides). + // Admins are NOT absolutely protected: the owner outranks them and CAN ban/hide an admin. + // (Admin-vs-admin peer protection — a lower rank can't act on an equal — is a later + // position-relative refinement gated on the author proof; the owner protection is the invariant.) + let mut protected: Vec = Vec::new(); + if let Some(owner) = owner_attestation + .as_ref() + .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex)) + { + protected.push(owner); + } + let banned: Vec = banned.into_iter().filter(|pk| !protected.contains(pk)).collect(); + + // Collect the channel head rows FIRST (drops the borrow on `conn`) so we can then query each + // channel's full epoch-key archive on the same connection without a borrow conflict. + let raw_channels: Vec<(String, Vec, i64, String)> = { + let mut stmt = conn + .prepare( + "SELECT channel_id, channel_key, epoch, name + FROM community_channels WHERE community_id = ?1 ORDER BY created_at", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![id_hex], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, Vec>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, String>(3)?, + )) + }) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string())? + }; + + // The AUTHORIZED roster (cached by fetch_and_apply_roles, post delegation check), denormalized + // onto each channel so the inbound delete path can verify a keyless moderation-hide. + let roster = get_community_roles(&id_hex).unwrap_or_default(); + + let mut channels = Vec::new(); + for (cid_hex, key_blob, epoch, cname) in raw_channels { + // Every retained epoch key for this channel (multi-held archive), so the read path can fetch + + // decrypt across rekeys. Best-effort: a read hiccup degrades to the head epoch (read_epoch_keys + // falls back), never an error. + let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = { + let mut ek_stmt = conn + .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2") + .map_err(|e| e.to_string())?; + let rows = ek_stmt + .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec>(1)?))) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for row in rows { + let (e, blob) = row.map_err(|e| e.to_string())?; + if let Ok(k) = dec_key(&blob) { + out.push((Epoch(e as u64), crate::community::ChannelKey(k))); + } + } + out + }; + channels.push(Channel { + id: ChannelId(hex_id_to_32(&cid_hex)?), + key: ChannelKey(dec_key(&key_blob)?), + // Stored as i64; reinterpreted back to u64 (two's-complement is exact, + // so the bit pattern round-trips losslessly even for epoch >= 2^63). + epoch: Epoch(epoch as u64), + name: dec_txt(&cname), + banned: banned.clone(), + protected: protected.clone(), + roster: roster.clone(), + epoch_keys, + dissolved, + }); + } + + Ok(Some(Community { + id: *id, + server_root_key, + // Stored as i64; reinterpreted to u64 (two's-complement is exact), same as channel epochs. + server_root_epoch: Epoch(server_root_epoch as u64), + name, + description, + icon, + banner, + relays, + channels, + owner_attestation, + dissolved, + })) +} + +/// Retain the ephemeral signing key of a message I published, so I can later +/// NIP-09-delete it. `relays` is where the deletion must be sent. +pub fn store_message_key( + message_id: &str, + outer_event_id: &str, + ephemeral: &Keys, + relays: &[String], +) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?; + let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?; + let enc_secret = enc_key(&sk_bytes)?; + let enc_relays = enc_txt(&relays_json)?; + conn.execute( + "INSERT OR REPLACE INTO community_message_keys + (outer_event_id, message_id, ephemeral_secret, relays, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + outer_event_id, + message_id, + &enc_secret[..], + enc_relays, + now_secs(), + ], + ) + .map_err(|e| format!("store message key: {e}"))?; + Ok(()) +} + +/// Read (WITHOUT removing) the retained key for a message by its INNER message id (what +/// the UI holds). Returns the ephemeral signing `Keys`, the OUTER event id to +/// NIP-09-delete, and the relay set — or `None` if not retained (someone else's message, +/// or already deleted). Peek-only so the key survives a failed deletion publish; the +/// caller removes it with [`delete_message_key`] only after the publish succeeds. +pub fn get_message_key(message_id: &str) -> Result)>, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let row = conn + .query_row( + "SELECT ephemeral_secret, outer_event_id, relays + FROM community_message_keys WHERE message_id = ?1", + params![message_id], + |r| Ok((r.get::<_, Vec>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)), + ) + .optional() + .map_err(|e| format!("get message key: {e}"))?; + let (secret_blob, outer_event_id, relays_json) = match row { + Some(t) => t, + None => return Ok(None), + }; + let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?; + let relays: Vec = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?; + Ok(Some((Keys::new(secret), outer_event_id, relays))) +} + +/// Remove a retained message key (after a successful deletion publish). +pub fn delete_message_key(message_id: &str) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "DELETE FROM community_message_keys WHERE message_id = ?1", + params![message_id], + ) + .map_err(|e| format!("remove message key: {e}"))?; + Ok(()) +} + +/// Peek + remove in one call. Prefer [`get_message_key`] + [`delete_message_key`] when a +/// fallible step sits between, so a failure doesn't strand the key. +pub fn take_message_key(message_id: &str) -> Result)>, String> { + let r = get_message_key(message_id)?; + if r.is_some() { + delete_message_key(message_id)?; + } + Ok(r) +} + +/// The hex id of the Community that owns `channel_id`, if any is stored locally. Used to +/// resolve a channel-addressed chat back to its Community for sending. +pub fn community_id_for_channel(channel_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + conn.query_row( + "SELECT community_id FROM community_channels WHERE channel_id = ?1", + params![channel_id], + |r| r.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("community_id_for_channel: {e}")) +} + +/// Whether a Community with this id is already stored locally (joined). Cheaper than +/// `load_community` when only existence matters (e.g. inbound-invite dedup). +pub fn community_exists(id: &CommunityId) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let found: Option = conn + .query_row( + "SELECT 1 FROM communities WHERE community_id = ?1", + params![id.to_hex()], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("community_exists: {e}"))?; + Ok(found.is_some()) +} + +/// A parked invite awaiting the user's accept/decline decision. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingCommunityInvite { + pub community_id: String, + pub bundle_json: String, + pub inviter_npub: String, + pub received_at: i64, +} + +/// Park an inbound invite bundle for explicit user consent (the carrier never +/// auto-joins). First-invite-wins: `INSERT OR IGNORE` means a later invite for the +/// same `community_id` can't silently rewrite a parked bundle. Returns whether a new +/// row was inserted (`false` = already pending, caller should not re-notify). +pub fn save_pending_invite( + community_id: &str, + bundle_json: &str, + inviter_npub: &str, +) -> Result { + /// Cap on parked invites. Each row is one gift-wrapped invite from an arbitrary sender, so an + /// attacker fabricating unbounded community_ids could otherwise grow this table without limit + /// (#298). Newest-wins: a stale months-old park is the safe thing to shed. + const MAX_PENDING_INVITES: usize = 100; + + let conn = crate::db::get_write_connection_guard_static()?; + let enc_bundle = enc_txt(bundle_json)?; + let enc_inviter = enc_txt(inviter_npub)?; + let changed = conn + .execute( + "INSERT OR IGNORE INTO pending_community_invites + (community_id, bundle_json, inviter_npub, received_at) + VALUES (?1, ?2, ?3, ?4)", + params![community_id, enc_bundle, enc_inviter, now_secs()], + ) + .map_err(|e| format!("save pending invite: {e}"))?; + // Only growth can breach the cap. Evict everything past the newest MAX rows + // (LIMIT -1 OFFSET cap = "all rows after the first cap"); community_id tie-breaks equal times. + if changed > 0 { + let _ = conn.execute( + "DELETE FROM pending_community_invites + WHERE community_id IN ( + SELECT community_id FROM pending_community_invites + ORDER BY received_at DESC, community_id DESC + LIMIT -1 OFFSET ?1 + )", + params![MAX_PENDING_INVITES], + ); + } + Ok(changed > 0) +} + +/// Drop every parked invite for a community we ALREADY hold — once joined on any device, the +/// invite must never resurface. Ordering-independent: covers the cross-device case where the +/// historical gift-wrapped invites are ingested BEFORE the synced membership list rehydrates +/// those communities (so the ingest-time `community_exists` guard saw nothing yet). Returns the +/// count purged. +pub fn purge_pending_invites_for_held_communities() -> Result { + let conn = crate::db::get_write_connection_guard_static()?; + let n = conn + .execute( + "DELETE FROM pending_community_invites + WHERE community_id IN (SELECT community_id FROM communities)", + [], + ) + .map_err(|e| format!("purge held pending invites: {e}"))?; + Ok(n) +} + +/// All parked invites, newest first. +pub fn list_pending_invites() -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare( + "SELECT community_id, bundle_json, inviter_npub, received_at + FROM pending_community_invites ORDER BY received_at DESC", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| { + Ok(PendingCommunityInvite { + community_id: r.get(0)?, + bundle_json: dec_txt(&r.get::<_, String>(1)?), + inviter_npub: dec_txt(&r.get::<_, String>(2)?), + received_at: r.get(3)?, + }) + }) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| e.to_string())?); + } + Ok(out) +} + +/// Read a parked invite's bundle WITHOUT removing it. Accept is fallible (caps, +/// owner/authority collision), so the row must survive a rejected accept — peek here, +/// then [`delete_pending_invite`] only after the join succeeds. +pub fn get_pending_invite(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let raw: Option = conn + .query_row( + "SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1", + params![community_id], + |r| r.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("get pending invite: {e}"))?; + Ok(raw.map(|s| dec_txt(&s))) +} + +/// Drop a parked invite without joining (the user declined). +pub fn delete_pending_invite(community_id: &str) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "DELETE FROM pending_community_invites WHERE community_id = ?1", + params![community_id], + ) + .map_err(|e| format!("delete pending invite: {e}"))?; + Ok(()) +} + +/// Whether an invite for this id is already parked (inbound dedup). +pub fn pending_invite_exists(community_id: &str) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let found: Option = conn + .query_row( + "SELECT 1 FROM pending_community_invites WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("pending_invite_exists: {e}"))?; + Ok(found.is_some()) +} + +/// A minted public-invite link the owner retains (to list + revoke). +#[derive(Debug, Clone, serde::Serialize)] +pub struct PublicInviteRecord { + /// Hex token (the link's whole secret; lives only in the local account DB). + pub token: String, + pub community_id: String, + pub url: String, + pub expires_at: Option, + pub created_at: i64, + /// Optional human label set at mint time (e.g. "Twitter", "Discord"). None if unset. + pub label: Option, + /// Distinct members who joined via this link (by label attribution). 0 if none/unknown. + #[serde(default)] + pub join_count: u64, +} + +/// Retain a minted public-invite token so the owner can later list + revoke it. +pub fn save_public_invite( + token: &str, + community_id: &str, + url: &str, + expires_at: Option, + label: Option<&str>, +) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + // token + url are the link's secret; encrypted, the token PK becomes per-write-unique (random + // nonce) so this is effectively an INSERT — fine, mints generate a fresh token each time. + let enc_token = enc_txt(token)?; + let enc_url = enc_txt(url)?; + // Encrypt the label at rest like the url; NULL when no label was set. + let enc_label = label.map(enc_txt).transpose()?; + conn.execute( + "INSERT OR REPLACE INTO community_public_invites + (token, community_id, url, expires_at, created_at, label) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label], + ) + .map_err(|e| format!("save public invite: {e}"))?; + Ok(()) +} + +/// All minted public-invite links for a Community, newest first. +pub fn list_public_invites(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare( + "SELECT token, community_id, url, expires_at, created_at, label + FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id], |r| { + Ok(PublicInviteRecord { + token: dec_txt(&r.get::<_, String>(0)?), + community_id: r.get(1)?, + url: dec_txt(&r.get::<_, String>(2)?), + expires_at: r.get(3)?, + created_at: r.get(4)?, + label: r.get::<_, Option>(5)?.map(|s| dec_txt(&s)), + join_count: 0, + }) + }) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| e.to_string())?); + } + // Fill per-link join counts (distinct joiners via each label, attributed to me). + if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) { + if let Ok(counts) = community_invite_join_counts(community_id, &me) { + for rec in &mut out { + if let Some(l) = rec.label.as_deref() { + rec.join_count = counts.get(l).copied().unwrap_or(0); + } + } + } + } + Ok(out) +} + +/// Forget a minted public-invite token (after revoking it on relays). +pub fn delete_public_invite(token: &str) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + // Stored tokens are encrypted (random nonce), so an equality DELETE can't match — scan, + // decrypt, and delete the row whose plaintext token matches (by rowid). Few rows, owner-only. + let rows: Vec<(i64, String)> = { + let mut stmt = conn + .prepare("SELECT rowid, token FROM community_public_invites") + .map_err(|e| e.to_string())?; + let mapped = stmt + .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) + .map_err(|e| e.to_string())?; + mapped.filter_map(|r| r.ok()).collect() + }; + for (rowid, stored) in rows { + if dec_txt(&stored) == token { + conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid]) + .map_err(|e| format!("delete public invite: {e}"))?; + } + } + Ok(()) +} + +/// All minted public-invite links across ALL communities (backfill source for the synced Invite List). +pub fn list_all_public_invites() -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare( + "SELECT token, community_id, url, expires_at, created_at, label + FROM community_public_invites ORDER BY created_at DESC", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| { + Ok(PublicInviteRecord { + token: dec_txt(&r.get::<_, String>(0)?), + community_id: r.get(1)?, + url: dec_txt(&r.get::<_, String>(2)?), + expires_at: r.get(3)?, + created_at: r.get(4)?, + label: r.get::<_, Option>(5)?.map(|s| dec_txt(&s)), + join_count: 0, + }) + }) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| e.to_string())?); + } + Ok(out) +} + +/// Insert a public-invite row only if its (decrypted) token isn't already present — idempotent hydration +/// from the synced Invite List, PRESERVING the original `created_at` (unlike `save_public_invite`, which +/// stamps now). Returns true if a row was inserted. Tokens are stored encrypted with a random nonce, so SQL +/// equality can't dedup; scan + decrypt (few rows per community, owner-only). +pub fn upsert_public_invite( + token: &str, + community_id: &str, + url: &str, + expires_at: Option, + created_at: i64, + label: Option<&str>, +) -> Result { + let conn = crate::db::get_write_connection_guard_static()?; + let already = { + let mut stmt = conn + .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1") + .map_err(|e| e.to_string())?; + let stored: Vec = stmt + .query_map(params![community_id], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + stored.iter().any(|s| dec_txt(s) == token) + }; + if already { + return Ok(false); + } + let enc_token = enc_txt(token)?; + let enc_url = enc_txt(url)?; + let enc_label = label.map(enc_txt).transpose()?; + conn.execute( + "INSERT INTO community_public_invites + (token, community_id, url, expires_at, created_at, label) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![enc_token, community_id, enc_url, expires_at, created_at, enc_label], + ) + .map_err(|e| format!("upsert public invite: {e}"))?; + Ok(true) +} + +/// Remove a Community and all its local state (channels, retained message keys, parked +/// invites, minted public-invite tokens). Used when the user leaves a Community — there +/// is no protocol "leave" (membership is key possession), so leaving is purely local: +/// drop the keys + stop subscribing. +pub fn delete_community(community_id: &str) -> Result<(), String> { + delete_community_inner(community_id, false) +} + +/// self-removal teardown: drop all local community state EXCEPT the held epoch keys +/// (`community_epoch_keys`). Read access to future epochs is already gone (the post-removal +/// keys are never delivered); retaining the OLD keys only preserves the ability to author a +/// `3305` self-delete of one's own past messages, each sealed under the epoch key it was sent +/// at. Used by every self-removal trigger (voluntary leave, kick of me, ban-rekey exclusion). +pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> { + delete_community_inner(community_id, true) +} + +fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + // Atomic: a crash/error mid-delete must not orphan channel/invite rows under a + // now-missing parent (community_id_for_channel would still resolve them). + let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?; + for sql in [ + Some("DELETE FROM communities WHERE community_id = ?1"), + Some("DELETE FROM community_channels WHERE community_id = ?1"), + // Multi-held epoch keys (base + per-channel, all epochs). RETAINED on a self-removal so a + // later self-scrub of own past messages stays possible; dropped on an explicit delete/re-join reset + // (else a re-join inherits stale rotated keys). + (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"), + Some("DELETE FROM community_public_invites WHERE community_id = ?1"), + Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"), + Some("DELETE FROM pending_community_invites WHERE community_id = ?1"), + // Per-entity edition heads (keyless model) — else stale refuse-downgrade floors + self_hash + // anchors survive a leave/re-join and reject a legitimately reset chain. + Some("DELETE FROM community_edition_heads WHERE community_id = ?1"), + ] + .into_iter() + .flatten() + { + tx.execute(sql, params![community_id]) + .map_err(|e| format!("delete community: {e}"))?; + } + tx.commit().map_err(|e| format!("delete community commit: {e}"))?; + // `community_message_keys` is INTENTIONALLY left intact: those are our OWN ephemeral signing keys for + // NIP-09-deleting our own messages. The right to erase our own content from relays outlives membership + // — even after a ban or leave we must keep the ability to purge what we sent — so they survive a + // community delete. (Keyed by message_id, no community_id; there is nothing community-scoped to drop.) + Ok(()) +} + +/// Observed participants: the best-effort member list of a Community, newest-active first. +/// Membership is NOT authoritative (a lurker who never posts and never announced won't appear). +/// A member is included when they have real activity — a posted message/reaction/edit, OR a +/// join presence (kind 3306) — UNLESS that is superseded by a more-recent leave, OR they are +/// banned. So a "leave" actually removes a member, and a leave-then-rejoin/post re-adds them. +/// `created_at` is in seconds. Result is capped (anti-flood); see [`COMMUNITY_MEMBER_CAP`]. +pub fn community_member_activity(community_id: &str) -> Result, String> { + /// Cap on rendered members — bounds a presence-flood (fresh-identity 3306 spam) from + /// growing the list / profile-fetch fan-out without limit. + const COMMUNITY_MEMBER_CAP: usize = 500; + use std::collections::HashMap; + + let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? { + Some(c) => c, + None => return Ok(Vec::new()), + }; + // The proven owner is ALWAYS a member of their own community. Seed them so a freshly-created + // community (no message/presence events yet) still shows its creator instead of an empty roster. + // `now_secs()` is just a presence baseline; real activity below overwrites it, and the UI re-sorts + // by role tier regardless. + let owner_b32: Option = community + .owner_attestation + .as_deref() + .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id)) + .and_then(|pk| pk.to_bech32().ok()); + + // Map each channel's hex id → its integer chat row id (skip channels with no events yet). + let mut chat_ints: Vec = Vec::new(); + for ch in &community.channels { + if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) { + chat_ints.push(cid); + } + } + + // APPLICATION_SPECIFIC (30078) is the kind for presence/system events; everything else in a + // community channel is real message activity. Inlined as a constant integer (no injection). + let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC; + + // active_at[npub] = newest real-activity time (any non-presence event), folded with joins below. The + // proven owner + roster grant-holders are NOT seeded here — they're re-asserted AFTER the leave/ban + // filter (else a stale message would overwrite the seed and a later `left` would wrongly cut a current + // admin — the retain-set inversion). See the re-assert block below. + let mut active: HashMap = HashMap::new(); + let mut left: HashMap = HashMap::new(); + // No channel has any events yet (e.g. fresh community) → skip the activity queries; the owner + roster + // are still surfaced by the post-filter re-assert below. + if !chat_ints.is_empty() { + let conn = crate::db::get_db_connection_guard_static()?; + let placeholders = chat_ints.iter().map(|_| "?").collect::>().join(","); + + { + let sql = format!( + "SELECT npub, MAX(created_at) FROM events \ + WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \ + GROUP BY npub" + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64)) + }) + .map_err(|e| e.to_string())?; + for row in rows { + let (npub, at) = row.map_err(|e| e.to_string())?; + active.insert(npub, at); + } + } + + // Fold presence: a join (event-type "1") is activity; a leave (event-type "0") may remove. + { + let sql = format!( + "SELECT npub, created_at, tags FROM events \ + WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''" + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?)) + }) + .map_err(|e| e.to_string())?; + for row in rows { + let (npub, at, tags_json) = row.map_err(|e| e.to_string())?; + // SystemEventType: 1 = MemberJoined, 0 = MemberLeft (carried in an ["event-type", n] tag). + let etype = serde_json::from_str::>>(&tags_json) + .ok() + .and_then(|tags| { + tags.into_iter() + .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false)) + .and_then(|t| t.into_iter().nth(1)) + }); + match etype.as_deref() { + Some("1") => { + let e = active.entry(npub).or_insert(0); + if at > *e { *e = at; } + } + Some("0") => { + let e = left.entry(npub).or_insert(0); + if at > *e { *e = at; } + } + _ => {} + } + } + } + } + + // Exclude banned (banlist is hex; events store bech32 — compare on bech32). Denormalized + // identically onto every channel at load, so reading channels[0] is sufficient. + let banned: std::collections::HashSet = community + .channels + .first() + .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect()) + .unwrap_or_default(); + + // Member iff active, not banned, and last activity is at-or-after the last leave. + let mut out: Vec<(String, u64)> = active + .into_iter() + .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l)) + .collect(); + + // RE-ASSERT authorized members AFTER the activity/leave filter: the proven owner + every + // non-empty-grant roster holder is a member regardless of stale activity or a `left` — a privatize/ban + // retain set must NEVER silently shed an authorized member (a leave or an old message must not drop a + // current admin; that read-cut would lock a sitting admin out of their own community). Banned is the + // only exclusion (a ban revokes the role anyway). Stamped `now_secs()` so they sort to the top and + // survive the cap. Computed POST-filter so neither the leave filter nor a stale overwrite can cut them. + { + let mut present: std::collections::HashSet = out.iter().map(|(n, _)| n.clone()).collect(); + let mut reassert = |npub: String| { + if !banned.contains(&npub) && present.insert(npub.clone()) { + out.push((npub, now_secs() as u64)); + } + }; + if let Some(o) = owner_b32 { + reassert(o); + } + if let Ok(roles) = get_community_roles(community_id) { + for g in &roles.grants { + if g.role_ids.is_empty() { + continue; // an empty grant is a revoked role, not a member + } + if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) { + reassert(b32); + } + } + } + } + out.sort_by(|a, b| b.1.cmp(&a.1)); + out.truncate(COMMUNITY_MEMBER_CAP); + Ok(out) +} + +/// Per-link join counts for the owner's public invites: `label -> distinct joiners` who joined +/// via a link minted by `inviter_npub` (bech32). Reads the `invited-by` / `invited-label` tags on +/// MemberJoined system events; distinct by joiner npub so a rejoin isn't double-counted. Labels are +/// unique per creator (random fallback ensures it), so (inviter, label) keys a single link. +pub fn community_invite_join_counts( + community_id: &str, + inviter_npub: &str, +) -> Result, String> { + use std::collections::{HashMap, HashSet}; + let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? { + Some(c) => c, + None => return Ok(HashMap::new()), + }; + let mut chat_ints: Vec = Vec::new(); + for ch in &community.channels { + if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) { + chat_ints.push(cid); + } + } + if chat_ints.is_empty() { + return Ok(HashMap::new()); + } + let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC; + let conn = crate::db::get_db_connection_guard_static()?; + let placeholders = chat_ints.iter().map(|_| "?").collect::>().join(","); + let sql = format!( + "SELECT npub, tags FROM events \ + WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''" + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + }) + .map_err(|e| e.to_string())?; + // label -> set of distinct joiner npubs + let mut per_label: HashMap> = HashMap::new(); + for row in rows { + let (joiner, tags_json) = row.map_err(|e| e.to_string())?; + let tags = match serde_json::from_str::>>(&tags_json) { + Ok(t) => t, + Err(_) => continue, + }; + let tag_val = |key: &str| -> Option { + tags.iter() + .find(|t| t.first().map(|s| s == key).unwrap_or(false)) + .and_then(|t| t.get(1).cloned()) + }; + // MemberJoined (event-type "1") attributed to THIS owner's link, with a label. + if tag_val("event-type").as_deref() != Some("1") { + continue; + } + if tag_val("invited-by").as_deref() != Some(inviter_npub) { + continue; + } + if let Some(label) = tag_val("invited-label") { + per_label.entry(label).or_default().insert(joiner); + } + } + Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect()) +} + +/// Replace a Community's stored banlist (JSON array of hex pubkeys) + the `created_at` (secs) of +/// the edition it came from. `at` is the version: the owner's own ban/unban writes its freshly +/// built event time, and `fetch_and_apply_banlist` only calls this with a strictly-newer edition, +/// so the stored banlist can never roll backwards. +pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> { + let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?; + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3", + params![json, at, community_id], + ) + .map_err(|e| format!("set banlist: {e}"))?; + Ok(()) +} + +/// The `created_at` (secs) of the banlist edition currently stored, or 0 if none. The version +/// floor the rollback guard compares against. +pub fn get_community_banlist_at(community_id: &str) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let at: Option = conn + .query_row( + "SELECT banlist_at FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get banlist_at: {e}"))?; + Ok(at.unwrap_or(0)) +} + +/// Replace a Community's cached role graph (the aggregated `CommunityRoles`) + the `created_at` +/// (secs) of the newest per-entity edition it was built from. `at` is the version floor: the +/// fetch path only calls this with a strictly-newer aggregate, so the role graph can't roll +/// backwards (same guard as the banlist). +pub fn set_community_roles( + community_id: &str, + roles: &crate::community::roles::CommunityRoles, + at: i64, +) -> Result<(), String> { + let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?; + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3", + params![json, at, community_id], + ) + .map_err(|e| format!("set roles: {e}"))?; + Ok(()) +} + +/// A Community's cached role graph. Empty (default) for an unknown community or none stored. +pub fn get_community_roles( + community_id: &str, +) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let json: Option = conn + .query_row( + "SELECT roles FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get roles: {e}"))?; + Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default()) +} + +/// The `created_at` (secs) of the role-graph edition currently stored, or 0 if none. +pub fn get_community_roles_at(community_id: &str) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let at: Option = conn + .query_row( + "SELECT roles_at FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get roles_at: {e}"))?; + Ok(at.unwrap_or(0)) +} + +/// Record the current head (version + self_hash) of a control entity's edition chain (keyless model). +/// The send side reads this to emit the next edition as `version+1` citing `self_hash` as `prev_hash`; +/// the fold uses it as the per-entity refuse-downgrade floor + anchor. Upserts per (community, entity). +/// `inner_id` is the head edition's deterministic tiebreak key (used only by [`converge_edition_head`]). +pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> { + set_edition_head_inner(community_id, entity_id, version, self_hash, None) +} + +/// As [`set_edition_head`], but also records the head edition's `inner_id` (the deterministic tiebreak +/// key), so a later same-version convergence can rank against it. A plain advance carries it through. +pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> { + set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id)) +} + +fn set_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: Option<&[u8; 32]>) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + // MONOTONIC, EPOCH-PRIMARY: the head IS the refuse-downgrade floor. The recorded `epoch` is + // the community's current server-root epoch (re-founding bumps it + resets versions to 1). A higher + // epoch ALWAYS supersedes (so a re-founding's v1 lands over a held v21); within an epoch, version + // still only advances. So a stale/hostile rollback can lower neither the epoch nor the in-epoch version. + conn.execute( + "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch) + VALUES (?1, ?2, ?3, ?4, ?5, COALESCE((SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)) + ON CONFLICT(community_id, entity_id) DO UPDATE SET + version = excluded.version, + self_hash = excluded.self_hash, + inner_id = excluded.inner_id, + epoch = excluded.epoch + WHERE excluded.epoch > community_edition_heads.epoch + OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)", + params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.map(|i| i.as_slice())], + ) + .map_err(|e| format!("set edition head: {e}"))?; + Ok(()) +} + +/// Converge the head to a same-version fork winner (concurrent-edit resolution). Unlike +/// [`set_edition_head`] (which only ADVANCES the version), this resolves a fork AT the current version: +/// two authorized editors editing concurrently from the same base both produce `version`, and every +/// client must adopt the SAME one. The winner is the lower deterministic `inner_id`, so this update +/// fires only when the incoming edition ties the stored version AND carries a strictly lower `inner_id` +/// — monotonic toward the global minimum, so it can never flip-flop (a relay can't churn the head by +/// reordering, and a held row with a NULL `inner_id`, pre-migration, is treated as "always replaceable" +/// so it heals to a ranked id). The version-advance path is unchanged and still handled by +/// [`set_edition_head_with_id`]; callers run BOTH (advance covers v+1, converge covers a same-v fork). +pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + // Scoped to the CURRENT epoch's head: a fork is resolved within an epoch, never across one (an epoch + // bump is a re-founding, handled by the advance path). `epoch` matches the community's current epoch. + conn.execute( + "UPDATE community_edition_heads + SET self_hash = ?4, inner_id = ?5 + WHERE community_id = ?1 AND entity_id = ?2 + AND version = ?3 + AND epoch = COALESCE((SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0) + AND (inner_id IS NULL OR ?5 < inner_id)", + params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice()], + ) + .map_err(|e| format!("converge edition head: {e}"))?; + Ok(()) +} + +/// The held head's tiebreak key (`inner_id`), or `None` if unheld or pre-migration (NULL). The consumer +/// uses this to decide a same-version convergence exactly as [`converge_edition_head`]'s SQL does (a +/// NULL/None held id is "always replaceable") — so it never applies a display edit the head write would +/// then refuse. +pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let row: Option>> = conn + .query_row( + "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2", + params![community_id, entity_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get edition head inner_id: {e}"))?; + match row.flatten() { + Some(blob) if blob.len() == 32 => { + let mut h = [0u8; 32]; + h.copy_from_slice(&blob); + Ok(Some(h)) + } + _ => Ok(None), + } +} + +/// The current head `(version, self_hash)` of a control entity's edition chain, or `None` if no +/// edition is held yet (so the next edition is the genesis, version 1, no prev_hash). +pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let row: Option<(i64, Vec)> = conn + .query_row( + "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2", + params![community_id, entity_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| format!("get edition head: {e}"))?; + match row { + Some((v, hash)) if hash.len() == 32 => { + let mut h = [0u8; 32]; + h.copy_from_slice(&hash); + Ok(Some((v as u64, h))) + } + _ => Ok(None), + } +} + +/// The set of control-entity ids (hex) this account tracks a head for. A base rotation gates its +/// head-advance on re-anchoring covering EVERY one of these (not just a matching count), so a relay +/// that withholds one entity's editions while over-serving another's can't slip a thinned control +/// plane past the rotator. +pub fn edition_head_entity_ids(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + let mut out = std::collections::HashSet::new(); + for row in rows { + out.insert(row.map_err(|e| e.to_string())?); + } + Ok(out) +} + + +/// Every tracked control entity's persisted head `(entity_id hex → (version, self_hash))`. This is the +/// per-entity refuse-downgrade FLOOR: the fold seeds each entity's chain from its held head, so a +/// withholding relay serving editions BELOW what we already hold can't roll an authority chain back +/// (e.g. resurrecting a since-revoked admin's old grant). An empty map = a bootstrapping joiner (folds +/// from genesis, floor 0). +pub fn get_all_edition_heads(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec>(2)?)) + }) + .map_err(|e| e.to_string())?; + let mut out = std::collections::HashMap::new(); + for row in rows { + let (entity, version, hash) = row.map_err(|e| e.to_string())?; + if hash.len() == 32 { + let mut h = [0u8; 32]; + h.copy_from_slice(&hash); + out.insert(entity, (version as u64, h)); + } + } + Ok(out) +} + +/// Every tracked head as `entity_hex → (epoch, version, self_hash)` — the epoch-primary floor. +/// The caller seeds the fold with ONLY the entities at the community's CURRENT epoch (a head recorded +/// at a PRIOR epoch belongs to a superseded founding, so its entity folds fresh from the new epoch's v1 +/// genesis). This is what lets a re-founding's compacted v1 plane land without a version-only downgrade. +pub fn get_all_edition_heads_epoched(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec>(3)?)) + }) + .map_err(|e| e.to_string())?; + let mut out = std::collections::HashMap::new(); + for row in rows { + let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?; + if hash.len() == 32 { + let mut h = [0u8; 32]; + h.copy_from_slice(&hash); + out.insert(entity, (epoch as u64, version as u64, h)); + } + } + Ok(out) +} + +/// A Community's current banlist (hex pubkeys). Empty for an unknown community or empty list. +pub fn get_community_banlist(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let json: Option = conn + .query_row( + "SELECT banlist FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get banlist: {e}"))?; + Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default()) +} + +/// Replace a Community's cached invite-link registry (active link locators, hex), folded from the +/// owner-signed vsk=5 edition. Empty = Private. The version floor lives in `community_edition_heads` +/// (the registry's own entity), so this is just the content cache (mirrors `set_community_banlist`). +pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> { + let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?; + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2", + params![json, community_id], + ) + .map_err(|e| format!("set invite registry: {e}"))?; + Ok(()) +} + +/// A Community's current invite-link registry (active link locators, hex). Empty for an unknown +/// community or a Private one. `is_public` = this is non-empty (computed mode). +pub fn get_community_invite_registry(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let json: Option = conn + .query_row( + "SELECT invite_registry FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get invite registry: {e}"))?; + Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default()) +} + +/// A folded per-creator public-invite-link set: the creator's pubkey (hex) and their active link +/// locators. Used to surface "X has N active invite links" in the UI. +pub struct InviteLinkSetRow { + pub creator_hex: String, + pub locators: Vec, +} + +/// Replace ALL of a Community's per-creator invite-link sets with the freshly-folded set (latest-wins). +/// Replacing wholesale (not upserting) drops a creator who has revoked every link, so the per-creator +/// view stays in lockstep with the flat registry computed in the same fold. +pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> { + let mut conn = crate::db::get_write_connection_guard_static()?; + let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?; + tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id]) + .map_err(|e| format!("clear invite-link-sets: {e}"))?; + for s in sets { + if s.locators.is_empty() { + continue; // a creator with no active links is just absent (count 0) + } + let enc_creator = enc_txt(&s.creator_hex)?; + let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?; + // Plain INSERT: the DELETE above cleared the community's rows and `sets` has distinct creators + // (an encrypted creator can't act as a dedup key anyway — random nonce per write). + tx.execute( + "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)", + params![community_id, enc_creator, enc_locators], + ) + .map_err(|e| format!("insert invite-link-set: {e}"))?; + } + tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?; + Ok(()) +} + +/// Upsert ONE creator's invite-link set (optimistic local update after the local user mints/revokes their +/// own links, mirroring the flat-registry merge). An empty set removes the row. +pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + // `creator` is encrypted (random nonce), so locate any existing row by decrypting + matching. + let existing_rowid: Option = { + let mut stmt = conn + .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) + .map_err(|e| e.to_string())?; + let mut found = None; + for row in rows { + let (rowid, stored) = row.map_err(|e| e.to_string())?; + if dec_txt(&stored) == creator_hex { + found = Some(rowid); + break; + } + } + found + }; + if locators.is_empty() { + if let Some(rowid) = existing_rowid { + conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid]) + .map_err(|e| format!("delete invite-link-set: {e}"))?; + } + return Ok(()); + } + let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?; + match existing_rowid { + Some(rowid) => { + conn.execute( + "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2", + params![enc_locators, rowid], + ) + .map_err(|e| format!("upsert invite-link-set: {e}"))?; + } + None => { + let enc_creator = enc_txt(creator_hex)?; + conn.execute( + "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)", + params![community_id, enc_creator, enc_locators], + ) + .map_err(|e| format!("upsert invite-link-set: {e}"))?; + } + } + Ok(()) +} + +/// Every creator's active invite-link set for a Community (creator hex + locators). Empty for a Private +/// community (or one not yet re-folded since this table was added). +pub fn get_invite_link_sets(community_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1") + .map_err(|e| format!("prepare invite-link-sets: {e}"))?; + let rows = stmt + .query_map(params![community_id], |r| { + let creator_hex: String = r.get(0)?; + let json: String = r.get(1)?; + Ok((creator_hex, json)) + }) + .map_err(|e| format!("query invite-link-sets: {e}"))?; + let mut out = Vec::new(); + for row in rows { + let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?; + let locators: Vec = serde_json::from_str(&dec_txt(&json)).unwrap_or_default(); + out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators }); + } + Ok(out) +} + +/// Mark (or clear) that a PRIVATE-community ban's base re-seal (read-cut) is OUTSTANDING — set when +/// the re-seal is attempted and cleared only when it succeeds, so a transient failure is retried later +/// instead of silently leaving a banned member with read access. +pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2", + params![pending as i64, community_id], + ) + .map_err(|e| format!("set read_cut_pending: {e}"))?; + Ok(()) +} + +/// Set the owner-dissolution SEAL on a community — PERMANENT + irreversible (no clear path; there +/// is no un-dissolve). Idempotent: re-setting an already-dissolved community is a harmless no-op. Once +/// set, the control fold stops advancing and the inbound path drops every subsequent event. +pub fn set_community_dissolved(community_id: &str) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE communities SET dissolved = 1 WHERE community_id = ?1", + params![community_id], + ) + .map_err(|e| format!("set dissolved: {e}"))?; + Ok(()) +} + +/// Whether a community has been sealed by a folded + owner-verified GroupDissolved tombstone. +/// `false` for an unknown community. +pub fn get_community_dissolved(community_id: &str) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let v: Option = conn + .query_row( + "SELECT dissolved FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get dissolved: {e}"))?; + Ok(v.unwrap_or(0) != 0) +} + +/// Whether a PRIVATE-community read-cut re-seal is still outstanding (a prior attempt failed). The ban +/// flow retries the re-seal whenever this is set. `false` for an unknown community. +pub fn get_read_cut_pending(community_id: &str) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let v: Option = conn + .query_row( + "SELECT read_cut_pending FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get read_cut_pending: {e}"))?; + Ok(v.unwrap_or(0) != 0) +} + +/// Set the base epoch a pending read-cut (re-founding) must reach. The re-seal rotates the base only +/// while `server_root_epoch < target`, so a retry never double-rotates a base that already advanced. Set +/// to `server_root_epoch + 1` on a fresh exclusion delta (ban add / privatize); left untouched on a pure +/// resume so the in-flight target is preserved. +pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2", + params![target as i64, community_id], + ) + .map_err(|e| format!("set read_cut_target_epoch: {e}"))?; + Ok(()) +} + +/// The base epoch a pending read-cut must reach (see [`set_read_cut_target_epoch`]). `0` for an unknown +/// community. Reinterpreted i64->u64 (lossless) for epochs >= 2^63. +pub fn get_read_cut_target_epoch(community_id: &str) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let v: Option = conn + .query_row( + "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1", + params![community_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get read_cut_target_epoch: {e}"))?; + Ok(v.unwrap_or(0) as u64) +} + +/// The base (server-root) epoch a channel was last rekeyed FOR during a read-cut — the per-channel +/// progress marker that lets a resumed re-founding skip channels already cut. `0` if unknown. +pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result { + let conn = crate::db::get_db_connection_guard_static()?; + let v: Option = conn + .query_row( + "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2", + params![community_id, channel_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?; + Ok(v.unwrap_or(0) as u64) +} + +/// Record that a channel's key has been rotated to cover base epoch `server_epoch` (a read-cut step). +/// Best-effort progress marker: written after the channel rekey lands, so a crash before it just re-rotates +/// the channel on resume (safe, the rekey is monotonic) rather than skipping a channel that needed cutting. +pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3", + params![server_epoch as i64, community_id, channel_id], + ) + .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?; + Ok(()) +} + +/// Ids of every locally-stored Community. +pub fn list_community_ids() -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT community_id FROM communities ORDER BY created_at") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + let mut ids = Vec::new(); + for row in rows { + ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?)); + } + Ok(ids) +} + +#[cfg(test)] +mod tests { + use super::*; + + static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + + /// A unique, syntactically-valid test npub per call (bech32 charset, correct + /// length). Uniqueness isolates each test's account DB so state can't bleed. + fn make_test_npub(n: u32) -> String { + const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + let mut payload = vec![b'q'; 58]; + let mut x = n as u64; + let mut i = 58; + while x > 0 && i > 0 { + i -= 1; + payload[i] = BECH32[(x as usize) % 32]; + x /= 32; + } + format!("npub1{}", std::str::from_utf8(&payload).unwrap()) + } + + fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) { + let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + crate::db::close_database(); + let tmp = tempfile::tempdir().unwrap(); + let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let account = make_test_npub(n); + std::fs::create_dir_all(tmp.path().join(&account)).unwrap(); + crate::db::set_app_data_dir(tmp.path().to_path_buf()); + crate::db::set_current_account(account.clone()).unwrap(); + crate::db::init_database(&account).unwrap(); + (tmp, guard) + } + + #[test] + fn edition_head_round_trips_and_upserts() { + let (_tmp, _guard) = init_test_db(); + let cid = "f".repeat(64); + let entity = "a".repeat(64); + + // No head yet → None (the next edition is genesis v1). + assert_eq!(get_edition_head(&cid, &entity).unwrap(), None); + + // Set v1, read it back exactly. + let h1 = [0x11u8; 32]; + set_edition_head(&cid, &entity, 1, &h1).unwrap(); + assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1))); + + // Upsert to v2 — the head advances in place (one row per (community, entity)). + let h2 = [0x22u8; 32]; + set_edition_head(&cid, &entity, 2, &h2).unwrap(); + assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2))); + + // MONOTONIC: a lower-or-equal version write is a no-op — the refuse-downgrade floor never + // rolls back, even against a stale or hostile rollback attempt. + set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap(); + assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored"); + set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap(); + assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too"); + + // A different entity is tracked independently. + let other = "b".repeat(64); + assert_eq!(get_edition_head(&cid, &other).unwrap(), None); + } + + #[test] + fn server_root_epoch_round_trips() { + // The base read clock survives save/load (default 0; a rotated value preserved exactly). + let (_tmp, _guard) = init_test_db(); + let mut c = Community::create("HQ", "general", vec![]); + save_community(&c).unwrap(); + assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0)); + + c.server_root_epoch = Epoch(5); + c.server_root_key = ServerRootKey([0x42u8; 32]); + save_community(&c).unwrap(); + let loaded = load_community(&c.id).unwrap().unwrap(); + assert_eq!(loaded.server_root_epoch, Epoch(5)); + assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]); + } + + #[test] + fn epoch_key_archive_retains_every_epoch() { + // a member who lived through a rotation must keep OLD epoch keys. Storing a new + // epoch's key must NOT clobber a prior one (the data-loss bug the archive fixes). + let (_tmp, _guard) = init_test_db(); + let cid = "f".repeat(64); + let scope = "a".repeat(64); + + store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap(); + store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap(); + store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap(); + + let held = held_epoch_keys(&cid, &scope).unwrap(); + assert_eq!(held.len(), 3, "all three epoch keys retained"); + assert_eq!(held[0], (Epoch(0), [0xA0u8; 32])); + assert_eq!(held[1], (Epoch(1), [0xA1u8; 32])); + assert_eq!(held[2], (Epoch(2), [0xA2u8; 32])); + + // Point lookup by epoch (what the open path uses to select a decryption key). + assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32])); + assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None"); + + // Same coordinate REPLACE = fork-resolution committing a winning key (only legit overwrite). + store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap(); + assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32])); + assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row"); + + // A different scope is isolated (server-root vs a channel share the table, never collide) — + // at both the list AND the point-lookup level. + assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty()); + assert_eq!( + held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), + None, + "epoch 1 under a different scope is not the channel's key" + ); + } + + #[test] + fn save_community_populates_the_epoch_archive() { + // save_community mirrors the current base + channel keys into the multi-held archive, so the + // foundation is live without any explicit store_epoch_key call by the caller. + let (_tmp, _guard) = init_test_db(); + let c = Community::create("HQ", "general", vec![]); + save_community(&c).unwrap(); + let cid = c.id.to_hex(); + + // Base key archived under the server-root sentinel at epoch 0. + assert_eq!( + held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(), + Some(c.server_root_key.as_bytes()) + ); + // The default channel's key archived under its channel id at epoch 0. + let chan = &c.channels[0]; + assert_eq!( + held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(), + Some(chan.key.as_bytes()) + ); + } + + #[test] + fn at_rest_encryption_wraps_keys_and_metadata_on_disk() { + let (_tmp, _guard) = init_test_db(); + // Local Encryption ON with a known vault key (the db-test guard serializes, so toggling these + // globals is safe; reset at the end). `others: &[]` — the slice only allocates a vault lane. + crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]); + crate::state::set_encryption_enabled(true); + + let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]); + c.server_root_key = ServerRootKey([0x42u8; 32]); + c.description = Some("top secret".into()); + save_community(&c).unwrap(); + let cid = c.id.to_hex(); + set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap(); + + // On disk: secrets are 60-byte ciphertext (12 nonce + 32 + 16 tag), NOT raw 32-byte keys; + // identifying text is hex ciphertext, never the plaintext. + { + let conn = crate::db::get_db_connection_guard_static().unwrap(); + let (root_len, name, banlist): (i64, String, String) = conn + .query_row( + "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1", + params![cid], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key"); + assert_ne!(name, "Secret HQ", "name must not be plaintext on disk"); + assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext"); + assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext"); + let key_len: i64 = conn + .query_row( + "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1", + params![cid], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(key_len, 60, "epoch-archive key must be ciphertext"); + } + + // In memory: load decrypts everything back to the originals. + let loaded = load_community(&c.id).unwrap().unwrap(); + assert_eq!(loaded.name, "Secret HQ"); + assert_eq!(loaded.description.as_deref(), Some("top secret")); + assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]); + assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]); + assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]); + + crate::state::set_encryption_enabled(false); + crate::state::ENCRYPTION_KEY.clear(&[]); + } + + #[test] + fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() { + // A row written BEFORE the at-rest pass (raw key + plaintext text) must still read back once + // encryption is on — the 32-vs-60 byte + `looks_encrypted` discriminators handle the mixed DB. + let (_tmp, _guard) = init_test_db(); + crate::state::set_encryption_enabled(false); + let mut c = Community::create("Legacy HQ", "general", vec![]); + c.server_root_key = ServerRootKey([0x42u8; 32]); + save_community(&c).unwrap(); + + crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]); + crate::state::set_encryption_enabled(true); + let loaded = load_community(&c.id).unwrap().unwrap(); + assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through"); + assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through"); + + crate::state::set_encryption_enabled(false); + crate::state::ENCRYPTION_KEY.clear(&[]); + } + + #[test] + fn save_and_load_round_trip() { + let (_tmp, _guard) = init_test_db(); + let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]); + save_community(&original).unwrap(); + + let loaded = load_community(&original.id).unwrap().expect("present"); + assert_eq!(loaded.id, original.id); + assert_eq!(loaded.name, "Vector HQ"); + assert_eq!(loaded.relays, original.relays); + // Secrets survive the round trip byte-for-byte. + assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes()); + // Channel survives with its key, epoch, and name. + assert_eq!(loaded.channels.len(), 1); + assert_eq!(loaded.channels[0].id, original.channels[0].id); + assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes()); + assert_eq!(loaded.channels[0].epoch, Epoch(0)); + assert_eq!(loaded.channels[0].name, "general"); + } + + #[test] + fn owner_is_protected_from_the_banlist_a_member_is_not() { + use nostr_sdk::JsonUtil; + let (_tmp, _guard) = init_test_db(); + let mut community = Community::create("HQ", "general", vec!["wss://r".into()]); + // Give it a proven owner (index 0). + let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap()); + community.owner_attestation = Some( + crate::community::owner::build_owner_attestation_unsigned( + owner_id.public_key(), + &community.id.to_hex(), + ) + .sign_with_keys(&owner_id) + .unwrap() + .as_json(), + ); + save_community(&community).unwrap(); + + // A banlist naming BOTH the owner and a regular member. + let member = Keys::generate(); + set_community_banlist( + &community.id.to_hex(), + &[owner_id.public_key().to_hex(), member.public_key().to_hex()], + 1, + ) + .unwrap(); + + let loaded = load_community(&community.id).unwrap().unwrap(); + let ch = &loaded.channels[0]; + // The owner is filtered OUT of the effective banlist (index 0 can't be banned)... + assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned"); + assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set"); + // ...but a regular member's ban stands. + assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored"); + } + + #[test] + fn loaded_keys_actually_decrypt() { + // The reconstructed keys must be usable: seal with the original channel key, + // open with the loaded one (proves the blob round-trip preserved key bytes). + let (_tmp, _guard) = init_test_db(); + let original = Community::create("HQ", "general", vec![]); + save_community(&original).unwrap(); + let loaded = load_community(&original.id).unwrap().unwrap(); + + let author = nostr_sdk::prelude::Keys::generate(); + let chan = &original.channels[0]; + let sealed = crate::community::envelope::seal_message( + &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1, + ) + .unwrap(); + let opened = crate::community::envelope::open_message( + &sealed, + &loaded.channels[0].key, + &loaded.channels[0].id, + loaded.channels[0].epoch, + ) + .unwrap(); + assert_eq!(opened.content, "persisted!"); + } + + #[test] + fn member_view_round_trips() { + // A joined member-view Community (keyless) persists + reloads with its + // server-root + channel keys intact. + let (_tmp, _guard) = init_test_db(); + let member = Community { + id: CommunityId([7u8; 32]), + server_root_key: ServerRootKey([8u8; 32]), + server_root_epoch: Epoch(0), + name: "Joined".into(), + description: None, + icon: None, + banner: None, + relays: vec!["wss://r".into()], + channels: vec![Channel { + id: ChannelId([9u8; 32]), + key: ChannelKey([10u8; 32]), + epoch: Epoch(0), + name: "general".into(), + banned: Vec::new(), + protected: Vec::new(), roster: Default::default(), + epoch_keys: Vec::new(), + dissolved: false, + }], + owner_attestation: None, + dissolved: false, + }; + save_community(&member).unwrap(); + let loaded = load_community(&member.id).unwrap().expect("present"); + assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]); + assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]); + } + + #[test] + fn large_epoch_round_trips_losslessly() { + // Epoch >= 2^63 stored as i64 then reinterpreted as u64 must be exact. + let (_tmp, _guard) = init_test_db(); + let mut c = Community::create("HQ", "g", vec![]); + c.channels[0].epoch = Epoch(u64::MAX - 7); + save_community(&c).unwrap(); + let loaded = load_community(&c.id).unwrap().unwrap(); + assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7)); + } + + #[test] + fn malformed_channel_id_row_errors_not_corrupts() { + // A corrupted (short/non-hex) channel_id must error on load, not silently + // reconstruct a wrong-but-self-consistent id. + let (_tmp, _guard) = init_test_db(); + let c = Community::create("HQ", "g", vec![]); + save_community(&c).unwrap(); + { + let conn = crate::db::get_write_connection_guard_static().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO community_channels + (channel_id, community_id, channel_key, epoch, name, created_at) + VALUES (?1, ?2, ?3, 0, 'bad', 0)", + rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]], + ) + .unwrap(); + } + assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt"); + } + + #[test] + fn message_key_store_take_round_trip() { + let (_tmp, _guard) = init_test_db(); + let eph = Keys::generate(); + let relays = vec!["wss://r.one".to_string()]; + // Keyed by INNER message id; resolves to the OUTER event id + key + relays. + store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap(); + + let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present"); + assert_eq!( + loaded.secret_key().as_secret_bytes(), + eph.secret_key().as_secret_bytes() + ); + assert_eq!(outer, "outer_evid"); + assert_eq!(r, relays); + // `take` is single-use: the row is removed. + assert!(take_message_key("inner_msg_id").unwrap().is_none()); + } + + #[test] + fn missing_community_is_none() { + let (_tmp, _guard) = init_test_db(); + let absent = CommunityId([0x33u8; 32]); + assert!(load_community(&absent).unwrap().is_none()); + } + + #[test] + fn list_ids_reflects_saved() { + let (_tmp, _guard) = init_test_db(); + let a = Community::create("A", "g", vec![]); + let b = Community::create("B", "g", vec![]); + save_community(&a).unwrap(); + save_community(&b).unwrap(); + let ids = list_community_ids().unwrap(); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&a.id) && ids.contains(&b.id)); + } + + #[test] + fn delete_community_clears_all_local_state() { + let (_tmp, _guard) = init_test_db(); + let c = Community::create("HQ", "general", vec!["r1".into()]); + save_community(&c).unwrap(); + let cid = c.id.to_hex(); + save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap(); + save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap(); + set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap(); + + // The save above archived the base + channel keys; this proves delete clears them too. + assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty()); + + delete_community(&cid).unwrap(); + assert!(!community_exists(&c.id).unwrap()); + assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none()); + assert!(list_public_invites(&cid).unwrap().is_empty()); + assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete"); + assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete"); + } + + #[test] + fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() { + // self-removal teardown: drop chat/membership/control state but KEEP the held epoch keys so a + // later self-scrub of own past messages stays possible. + let (_tmp, _guard) = init_test_db(); + let c = Community::create("HQ", "general", vec!["r1".into()]); + save_community(&c).unwrap(); + let cid = c.id.to_hex(); + save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap(); + set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap(); + + let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(); + let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(); + assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys"); + + delete_community_retain_keys(&cid).unwrap(); + + // State is gone. + assert!(!community_exists(&c.id).unwrap()); + assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none()); + assert!(list_public_invites(&cid).unwrap().is_empty()); + assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None); + // Epoch keys (base + channel, every epoch) survive intact. + assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before, + "base epoch keys retained for self-scrub"); + assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before, + "channel epoch keys retained for self-scrub"); + } + + #[test] + fn channel_resolves_to_owning_community() { + let (_tmp, _guard) = init_test_db(); + let c = Community::create("HQ", "general", vec![]); + save_community(&c).unwrap(); + let chan = c.channels[0].id.to_hex(); + assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str())); + assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none()); + } + + #[test] + fn community_exists_reflects_saved() { + let (_tmp, _guard) = init_test_db(); + let c = Community::create("A", "g", vec![]); + assert!(!community_exists(&c.id).unwrap()); + save_community(&c).unwrap(); + assert!(community_exists(&c.id).unwrap()); + } + + #[test] + fn pending_invite_first_wins_and_round_trips() { + let (_tmp, _guard) = init_test_db(); + let cid = "ab".repeat(32); + // First park inserts; a re-invite for the same id is IGNORED (first-wins, so a + // hostile re-send can't rewrite a parked bundle or re-notify). + assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap()); + assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap()); + assert!(pending_invite_exists(&cid).unwrap()); + + let listed = list_pending_invites().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].community_id, cid); + assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved"); + assert_eq!(listed[0].inviter_npub, "npub1inviter"); + + // get is non-destructive; delete then removes it. + assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}")); + assert!(pending_invite_exists(&cid).unwrap(), "get must not delete"); + delete_pending_invite(&cid).unwrap(); + assert!(!pending_invite_exists(&cid).unwrap()); + assert!(get_pending_invite(&cid).unwrap().is_none()); + } + + #[test] + fn purge_drops_invites_for_held_communities_only() { + let (_tmp, _guard) = init_test_db(); + // A community we hold + a parked invite for it (the cross-device race: invite landed + // before the membership list rehydrated the community). + let held = Community::create("Held", "general", vec![]); + save_community(&held).unwrap(); + let held_hex = held.id.to_hex(); + save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap(); + // An invite for a community we do NOT hold must survive the purge. + let stranger = "ab".repeat(32); + save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap(); + + let n = purge_pending_invites_for_held_communities().unwrap(); + assert_eq!(n, 1, "only the held community's invite is purged"); + assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone"); + assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept"); + } + + #[test] + fn decline_drops_pending_invite() { + let (_tmp, _guard) = init_test_db(); + let cid = "cd".repeat(32); + save_pending_invite(&cid, "{}", "npub1x").unwrap(); + delete_pending_invite(&cid).unwrap(); + assert!(!pending_invite_exists(&cid).unwrap()); + } + + #[test] + fn pending_invites_are_capped_keeping_the_newest() { + let (_tmp, _guard) = init_test_db(); + // 150 distinct invites with strictly increasing received_at (the helper stamps now_secs(), + // so vary the id and rely on insertion order; to make ordering deterministic we bump the + // stored time directly after each insert isn't needed — received_at ties break on id DESC). + // Insert 150; the table must cap at 100. + for i in 0..150u32 { + let cid = format!("{:064x}", i); + save_pending_invite(&cid, "{}", "npub1x").unwrap(); + } + let all = list_pending_invites().unwrap(); + assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES"); + // A spam flood can't grow it past the cap regardless of how many arrive. + for i in 150..400u32 { + let cid = format!("{:064x}", i); + save_pending_invite(&cid, "{}", "npub1x").unwrap(); + } + assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood"); + } +} diff --git a/crates/vector-core/src/concord/v1/derive.rs b/crates/vector-core/src/concord/v1/derive.rs new file mode 100644 index 00000000..2817aaaa --- /dev/null +++ b/crates/vector-core/src/concord/v1/derive.rs @@ -0,0 +1,481 @@ +//! Key-derivation convention (GROUP_PROTOCOL.md) — FROZEN. +//! +//! Every HKDF use in the Community protocol funnels through here. Changing any +//! byte of the construction shifts every pseudonym and sub-key, orphaning all +//! prior events — a forced migration to avoid. The layout is +//! locked by the golden vectors in the test module; treat those as the spec. +//! +//! Construction: `HKDF-SHA256(IKM, salt=∅, info, L=32)`, where +//! `info = utf8(label) || 0x00 || id32 || epoch_be` — +//! - `label` : ASCII purpose string, no terminator +//! - `0x00` : single separator byte +//! - `id32` : raw 32-byte id (channel id, or scope id), never hex +//! - `epoch_be` : the epoch as u64 big-endian (8 bytes); omitted where noted + +use hkdf::Hkdf; +use sha2::Sha256; + +use super::{ChannelId, ChannelKey, CommunityId, Epoch, Pseudonym, ServerRootKey}; +use nostr_sdk::prelude::SecretKey; + +/// Purpose labels. These strings are part of the wire format — append new +/// ones, never edit or reuse an existing one. +const LABEL_CHANNEL_PSEUDONYM: &str = "vector-community/v1/channel-pseudonym"; +const LABEL_RECIPIENT_PSEUDONYM: &str = "vector-community/v1/recipient-pseudonym"; +const LABEL_REKEY_PSEUDONYM: &str = "vector-community/v1/rekey-pseudonym"; +const LABEL_BASE_REKEY_PSEUDONYM: &str = "vector-community/v1/base-rekey-pseudonym"; +const LABEL_PUBLIC_INVITE_KEY: &str = "vector-community/v1/public-invite-key"; +const LABEL_PUBLIC_INVITE_LOCATOR: &str = "vector-community/v1/public-invite-locator"; +const LABEL_PUBLIC_INVITE_SIGNER: &str = "vector-community/v1/public-invite-signer"; +const LABEL_BANLIST_LOCATOR: &str = "vector-community/v1/banlist-locator"; +const LABEL_GRANT_LOCATOR: &str = "vector-community/v1/grant-locator"; +const LABEL_INVITE_LINKS_LOCATOR: &str = "vector-community/v1/invite-links-locator"; +const LABEL_DISSOLVED_LOCATOR: &str = "vector-community/v1/dissolved-locator"; +const LABEL_DISSOLVED_PSEUDONYM: &str = "vector-community/v1/dissolved-pseudonym"; +const LABEL_DISSOLVED_ENVELOPE: &str = "vector-community/v1/dissolved-envelope-key"; + +/// Opaque coordinate for the banlist entity, HKDF-derived from the **community id** — a STABLE +/// logical id that survives a server-root rotation, so a re-anchored banlist binds the same coordinate +/// at every epoch (re-anchoring). Member-computable (members hold the community id from their +/// invite), outsider-opaque (the id is never on the wire — the relay sees only the rotating +/// `control_pseudonym`), and the content stays server-root-encrypted, so privacy is unchanged. +pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] { + hkdf_sha256_32(&community_id.0, LABEL_BANLIST_LOCATOR.as_bytes()) +} + +/// Opaque coordinate for the owner-dissolution tombstone (vsk=10), HKDF-derived from the **community +/// id** — STABLE across a server-root rotation, exactly like `banlist_locator`. This rotation-stability is +/// load-bearing for dissolution: a fresh joiner after a re-founding derives only the NEW epoch root, but +/// can still compute this community-scoped coordinate and discover the tombstone, so a dissolved community +/// can never look "alive" to anyone who can derive the community id. Member-computable, outsider-opaque. +pub fn dissolved_locator(community_id: &CommunityId) -> [u8; 32] { + hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_LOCATOR.as_bytes()) +} + +/// Rotation-stable relay `#z` for the dissolution tombstone — community-id-derived (NOT the per-epoch +/// `control_pseudonym`), so ANY client that can derive the community id finds the tombstone at the SAME +/// coordinate regardless of which epoch root it holds. This is what closes the post-rotation +/// discoverability split: a fresh joiner who only ever derives a later epoch's root still probes this +/// fixed coordinate and learns the community is dead. Outsider-opaque (community id is never on the wire). +pub fn dissolved_pseudonym(community_id: &CommunityId) -> String { + crate::simd::hex::bytes_to_hex_32(&hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_PSEUDONYM.as_bytes())) +} + +/// Rotation-stable envelope key for the dissolution tombstone — community-id-derived so the tombstone is +/// openable by any member or joiner at ANY epoch. The control plane is server-root-encrypted (per-epoch), +/// which a post-rotation joiner can't open for the publish-epoch; the tombstone carries no secret (content +/// is `{}`), so a community-id key is the right scope — member-computable, outsider-opaque, epoch-free. +pub fn dissolved_envelope_key(community_id: &CommunityId) -> [u8; 32] { + hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_ENVELOPE.as_bytes()) +} + +/// Opaque coordinate for a CREATOR's own invite-links entity (vsk=8) — the per-creator list of +/// active public-invite-link locators THEY published. Bound to the creator's x-only pubkey exactly like a +/// per-member grant (`grant_locator`), so a creator can only publish links at their own coordinate, and +/// members fold every creator's list into the aggregate active-set (`is_public` = aggregate non-empty). +/// Community-id-derived (stable across rotation, member-computable, outsider-opaque). There is no shared +/// registry — each creator owns only their own list (per-creator ownership). +pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] { + let info = build_info(LABEL_INVITE_LINKS_LOCATOR, creator_xonly, None); + hkdf_sha256_32(&community_id.0, &info) +} + +/// Opaque coordinate for a member's Grant entity (vsk=3), HKDF-derived from the **community id** +/// bound to the member's x-only pubkey. Community-scoped (not server-root-scoped) so the coordinate is +/// STABLE across a base rotation — the keystone that lets a re-anchored grant fold under the new root +/// (re-anchoring): a new joiner holding only the new root still derives the same `entity_id`. +/// Member-computable, outsider-opaque (community id never on the wire), content still server-root- +/// encrypted — privacy unchanged. Roles need no locator (their `d`-tag is the role's random id). +pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] { + let info = build_info(LABEL_GRANT_LOCATOR, member_xonly, None); + hkdf_sha256_32(&community_id.0, &info) +} + +/// Scope of a per-recipient rekey blob. Disambiguates two blobs a single +/// sender delivers to the same recipient in one epoch (a server-root rotation and +/// a channel rekey), which would otherwise collide on the same tag. +#[derive(Debug, Clone, Copy)] +pub enum RekeyScope { + /// A specific channel being rekeyed. + Channel(ChannelId), + /// A server-wide root rotation — not channel-scoped, uses the all-zero sentinel. + ServerRoot, +} + +impl RekeyScope { + /// The 32-byte scope id this rekey binds: the channel id, or the all-zero server-root + /// sentinel. Used by `recipient_pseudonym`, the epoch-keys archive scope, and the blob binding. + pub fn id32(&self) -> [u8; 32] { + match self { + RekeyScope::Channel(c) => c.0, + RekeyScope::ServerRoot => [0u8; 32], + } + } +} + +/// Build the frozen `info` byte string. `epoch` is `None` for the no-epoch derivations +/// (the grant + invite-links locators and the public-invite sub-keys). +fn build_info(label: &str, id32: &[u8; 32], epoch: Option) -> Vec { + let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8); + info.extend_from_slice(label.as_bytes()); + info.push(0x00); + info.extend_from_slice(id32); + if let Some(e) = epoch { + info.extend_from_slice(&e.0.to_be_bytes()); + } + info +} + +/// HKDF-SHA256 expand to 32 bytes with an empty salt. +/// +/// RFC 5869 with no salt uses HashLen zero bytes; the `hkdf` crate's `new(None, ..)` +/// does exactly that, and for HMAC-SHA256 a zero-length salt and a 32-zero-byte salt +/// produce an identical PRK (both pad to the 64-byte block), so this matches the +/// spec's "salt=∅". The expand never fails for L=32 (≤ 255·HashLen). +fn hkdf_sha256_32(ikm: &[u8; 32], info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, ikm); + let mut okm = [0u8; 32]; + hk.expand(info, &mut okm) + .expect("HKDF expand of 32 bytes is infallible"); + okm +} + +/// Channel pseudonym: the value carried in the relay-filterable `z` tag. +/// Every member derives the same one from the shared channel secret, so it both +/// addresses and (by rotation) unlinks the channel's traffic. +pub fn channel_pseudonym(channel_key: &ChannelKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_CHANNEL_PSEUDONYM, &channel_id.0, Some(epoch)); + Pseudonym(hkdf_sha256_32(channel_key.as_bytes(), &info)) +} + +/// The relay-filterable address of a channel REKEY event for `(channel, epoch)`. Derived from +/// the **server-root key** (NOT the channel key) + the channel id + the epoch the rekey introduces. +/// Because the IKM is the server root — which every member always holds and which is stable across a +/// channel rotation — any member can compute this for ANY epoch directly, WITHOUT holding that epoch's +/// (or the prior epoch's) channel key. That is what makes epochs **independently recoverable**: a +/// member fetches the rekey for whichever epoch(s) they choose (latest only, or all, in parallel), +/// rather than chaining forward one key at a time. Distinct from the channel message pseudonym (channel +/// key IKM) and the control pseudonym (community-id binding) by IKM/id, and domain-separated by label. +pub fn rekey_pseudonym(server_root: &ServerRootKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_REKEY_PSEUDONYM, &channel_id.0, Some(epoch)); + Pseudonym(hkdf_sha256_32(server_root.as_bytes(), &info)) +} + +/// The relay-filterable address of a SERVER-ROOT (base) rekey for `(community, new_epoch)`. +/// Keyed by the **PRIOR** server-root key — the base layer has no stable key above it, so the prior +/// root is the handle every current member holds: a returning member derives this from the root they +/// currently hold, finds the base rekey, learns the rotator from its inner sig, and recovers the next +/// root (a short forward-walk; base rotations are rare). Binds the community id + epoch, and is +/// label-separated from the channel-rekey / channel-message / control pseudonyms. +pub fn base_rekey_pseudonym(prior_root: &ServerRootKey, community_id: &CommunityId, new_epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_BASE_REKEY_PSEUDONYM, &community_id.0, Some(new_epoch)); + Pseudonym(hkdf_sha256_32(prior_root.as_bytes(), &info)) +} + +/// Per-recipient rekey-blob tag. `IKM` is the pairwise sender↔recipient +/// ECDH secret (not the channel key), so only that pair can locate the blob and a +/// removed member cannot derive tags for pairs they are not in. +pub fn recipient_pseudonym(per_recipient_secret: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_RECIPIENT_PSEUDONYM, &scope.id32(), Some(epoch)); + Pseudonym(hkdf_sha256_32(per_recipient_secret, &info)) +} + +/// Reduce HKDF output to a valid secp256k1 scalar with reject-and-retry (the reject +/// branch is ~2^-128 rare but kept deterministic via a counter byte appended to +/// `info`, so derivation stays reproducible cross-implementation). +fn hkdf_to_secret_key(ikm: &[u8; 32], base_info: Vec) -> SecretKey { + let mut counter: u8 = 0; + loop { + let info = if counter == 0 { + base_info.clone() + } else { + let mut extended = base_info.clone(); + extended.push(counter); + extended + }; + let okm = hkdf_sha256_32(ikm, &info); + if let Ok(sk) = SecretKey::from_slice(&okm) { + return sk; + } + counter = counter + .checked_add(1) + .expect("secp256k1 scalar rejection 256 times running is impossible"); + } +} + +/// Public-invite sub-keys, all derived from the URL fetch-token. The token +/// is the IKM and there is no channel/epoch context, so the frozen `info` uses the +/// all-zero id and no epoch — the token alone provides uniqueness. The three labels +/// domain-separate the decryption key, the relay locator (addressable `d`-tag), and the +/// bundle's signing key (so the owner can re-post under one coordinate to rotate, and +/// joiners reject an impostor squatting the locator). +pub fn public_invite_key(token: &[u8; 32]) -> [u8; 32] { + hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_KEY, &[0u8; 32], None)) +} + +pub fn public_invite_locator(token: &[u8; 32]) -> [u8; 32] { + hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_LOCATOR, &[0u8; 32], None)) +} + +pub fn public_invite_signer(token: &[u8; 32]) -> SecretKey { + hkdf_to_secret_key(token, build_info(LABEL_PUBLIC_INVITE_SIGNER, &[0u8; 32], None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Fixed test inputs. The golden hex below was produced by an INDEPENDENT + // HKDF-SHA256 implementation (Python hmac+hashlib, RFC 5869) over these exact + // bytes, so a match proves the construction is correct cross-implementation, + // not merely self-consistent. If any of these assertions ever change, the wire + // format changed — that must be a conscious, versioned decision. + fn test_channel_key() -> ChannelKey { + // 0x00,0x01,..,0x1f + let mut k = [0u8; 32]; + for (i, b) in k.iter_mut().enumerate() { + *b = i as u8; + } + ChannelKey(k) + } + + fn test_channel_id() -> ChannelId { + // 0xff,0xfe,.. + let mut id = [0u8; 32]; + for (i, b) in id.iter_mut().enumerate() { + *b = (255 - i) as u8; + } + ChannelId(id) + } + + #[test] + fn channel_pseudonym_is_deterministic() { + let key = test_channel_key(); + let id = test_channel_id(); + let a = channel_pseudonym(&key, &id, Epoch(0)); + let b = channel_pseudonym(&key, &id, Epoch(0)); + assert_eq!(a, b, "same inputs must yield the same pseudonym"); + } + + #[test] + fn channel_pseudonym_golden_epoch0() { + let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0)); + assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH0); + } + + #[test] + fn channel_pseudonym_golden_epoch1() { + let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(1)); + assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH1); + } + + // Independent (Python hmac+hashlib, RFC 5869) over IKM=0x11*32 (the COMMUNITY id), member=0x22*32, + // info = "vector-community/v1/grant-locator" ‖ 0x00 ‖ member. + const GOLDEN_GRANT_LOCATOR: &str = + "c18d4d5955ecdd258f44240019a493a01fc01d51b5f0b8f7679ae424f8d5bfcc"; + + #[test] + fn grant_locator_golden() { + let loc = grant_locator(&crate::community::CommunityId([0x11u8; 32]), &[0x22u8; 32]); + assert_eq!(crate::simd::hex::bytes_to_hex_32(&loc), GOLDEN_GRANT_LOCATOR); + } + + #[test] + fn invite_links_locator_golden_and_domain_separated() { + let cid = crate::community::CommunityId([0x11u8; 32]); + let alice = [0x22u8; 32]; + let bob = [0x33u8; 32]; + // Frozen output (drift = a silent coordinate change → members lose a creator's links). + assert_eq!( + crate::simd::hex::bytes_to_hex_32(&invite_links_locator(&cid, &alice)), + "cf42937a815ec561da6b4ca5ddd0c361634b0d9744693b744d4f5b34ec209ec2" + ); + // Per-creator: each creator's list lives at a DISTINCT coordinate (no shared registry). + assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&cid, &bob)); + // Domain-separated from grant + banlist despite sharing the community-id IKM (distinct label). + assert_ne!(invite_links_locator(&cid, &alice), grant_locator(&cid, &alice)); + assert_ne!(invite_links_locator(&cid, &alice), banlist_locator(&cid)); + // Community-bound (a different community → a different coordinate). + assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&crate::community::CommunityId([0x99u8; 32]), &alice)); + } + + #[test] + fn grant_locator_binds_member_and_community() { + let cid = crate::community::CommunityId([0x11u8; 32]); + // Deterministic for the same inputs. + assert_eq!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x22u8; 32])); + // The member pubkey is bound in: a different member → a different locator. + assert_ne!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x23u8; 32])); + // A different COMMUNITY → a different locator (so coordinates don't collide across communities, + // and an outsider without the community id can't compute any). + assert_ne!( + grant_locator(&cid, &[0x22u8; 32]), + grant_locator(&crate::community::CommunityId([0x99u8; 32]), &[0x22u8; 32]) + ); + } + + #[test] + fn epoch_changes_the_pseudonym() { + let key = test_channel_key(); + let id = test_channel_id(); + assert_ne!( + channel_pseudonym(&key, &id, Epoch(0)), + channel_pseudonym(&key, &id, Epoch(1)), + "rotating the epoch must rotate the pseudonym (unlinkability)" + ); + } + + #[test] + fn different_channel_id_changes_the_pseudonym() { + let key = test_channel_key(); + let other = ChannelId([0x42u8; 32]); + assert_ne!( + channel_pseudonym(&key, &test_channel_id(), Epoch(0)), + channel_pseudonym(&key, &other, Epoch(0)), + ); + } + + #[test] + fn different_label_does_not_collide() { + // Channel pseudonym and recipient pseudonym share IKM-shape + id + epoch but + // differ only by label — domain separation must keep them distinct. + let secret = test_channel_key(); + let id = test_channel_id(); + let chan = channel_pseudonym(&secret, &id, Epoch(0)); + let recip = recipient_pseudonym(secret.as_bytes(), RekeyScope::Channel(id), Epoch(0)); + assert_ne!(chan.0, recip.0, "labels must domain-separate"); + } + + #[test] + fn recipient_pseudonym_golden() { + let secret = [7u8; 32]; + let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3)); + let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3)); + assert_eq!(chan.to_hex(), GOLDEN_RECIPIENT_CHANNEL_EPOCH3); + assert_eq!(root.to_hex(), GOLDEN_RECIPIENT_SERVERROOT_EPOCH3); + } + + #[test] + fn rekey_pseudonym_is_server_root_derived_and_distinct() { + let sr = ServerRootKey([0x07u8; 32]); + let chan = test_channel_id(); + // Deterministic + golden (regression pin for the channel-rekey address derivation). + let p = rekey_pseudonym(&sr, &chan, Epoch(1)); + assert_eq!(p, rekey_pseudonym(&sr, &chan, Epoch(1))); + assert_eq!(p.to_hex(), GOLDEN_REKEY_PSEUDONYM); + // Server-root-derived: a different root → different address (so a non-member can't compute it, + // and crucially a member needs ONLY the server root — not the channel key — to find it). + assert_ne!(p, rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &chan, Epoch(1))); + // Per-epoch + per-channel binding. + assert_ne!(p, rekey_pseudonym(&sr, &chan, Epoch(2))); + assert_ne!(p, rekey_pseudonym(&sr, &ChannelId([0x42u8; 32]), Epoch(1))); + // Domain-separated from the channel message pseudonym even with the same (id, epoch): the + // message pseudonym keys off the CHANNEL key, this off the SERVER ROOT + a different label. + let as_chan_key = channel_pseudonym(&ChannelKey(*sr.as_bytes()), &chan, Epoch(1)); + assert_ne!(p.0, as_chan_key.0, "label must domain-separate rekey-address from channel-message"); + // The subtle pairing: rekey vs control plane share IKM=server_root AND epoch — separation rests + // ENTIRELY on the label (and id namespace). Pin it so a future label edit can't collapse them. + let as_control = + crate::community::roster::control_pseudonym(&sr, &crate::community::CommunityId(chan.0), Epoch(1)); + assert_ne!(p.to_hex(), as_control, "label must domain-separate rekey-address from control-plane"); + } + + #[test] + fn base_rekey_pseudonym_is_prior_root_derived_and_distinct() { + let root = ServerRootKey([0x07u8; 32]); + let community = crate::community::CommunityId([0x09u8; 32]); + let p = base_rekey_pseudonym(&root, &community, Epoch(1)); + assert_eq!(p, base_rekey_pseudonym(&root, &community, Epoch(1))); + assert_eq!(p.to_hex(), GOLDEN_BASE_REKEY_PSEUDONYM); + // Keyed by the PRIOR root: a different root → different address (so a member needs the root they + // hold to find the next base rekey — the forward-walk handle). + assert_ne!(p, base_rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &community, Epoch(1))); + // Per-epoch + per-community binding. + assert_ne!(p, base_rekey_pseudonym(&root, &community, Epoch(2))); + assert_ne!(p, base_rekey_pseudonym(&root, &crate::community::CommunityId([0x42u8; 32]), Epoch(1))); + // Distinct from the control pseudonym (same IKM=root + community id + epoch) by label. + let control = super::super::roster::control_pseudonym(&root, &community, Epoch(1)); + assert_ne!(p.to_hex(), control, "label must domain-separate base-rekey from control-plane"); + } + + #[test] + fn server_root_scope_sentinel_matches_rekey_scope() { + // The epoch-keys archive scopes the base key under `SERVER_ROOT_SCOPE_HEX`; it must equal the + // hex of `RekeyScope::ServerRoot`'s all-zero `id32`, so the storage layer and the recipient + // pseudonym name the same server-root scope. Pinning this stops the two from drifting apart. + assert_eq!( + crate::simd::hex::bytes_to_hex_32(&RekeyScope::ServerRoot.id32()), + crate::community::SERVER_ROOT_SCOPE_HEX + ); + } + + #[test] + fn recipient_scope_disambiguates() { + // Same sender, same recipient, same epoch, but a channel rekey vs a + // server-root rotation must land on different tags (no blob collision). + let secret = [7u8; 32]; + let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3)); + let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3)); + assert_ne!(chan.0, root.0); + } + + #[test] + fn channel_pseudonym_golden_multibyte_epoch_is_big_endian() { + // A multi-byte epoch pins big-endian serialization explicitly (epoch 0/1 + // alone could be satisfied by either order beyond the low byte). + let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0x0102030405060708)); + assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE); + } + + #[test] + fn public_invite_subkeys_golden() { + // Independent RFC-5869 HKDF over token=[5;32], each label, all-zero id, no epoch. + let token = [5u8; 32]; + assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_key(&token)), GOLDEN_PUBLIC_INVITE_KEY); + assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_locator(&token)), GOLDEN_PUBLIC_INVITE_LOCATOR); + assert_eq!(public_invite_signer(&token).to_secret_hex(), GOLDEN_PUBLIC_INVITE_SIGNER); + } + + #[test] + fn public_invite_subkeys_domain_separated_and_token_bound() { + let token = [5u8; 32]; + let other = [6u8; 32]; + // Three sub-keys from one token must all differ (domain separation). + assert_ne!(public_invite_key(&token), public_invite_locator(&token)); + assert_ne!( + public_invite_key(&token).to_vec(), + public_invite_signer(&token).as_secret_bytes().to_vec() + ); + // A different token yields different sub-keys (token-bound). + assert_ne!(public_invite_key(&token), public_invite_key(&other)); + assert_ne!(public_invite_locator(&token), public_invite_locator(&other)); + } + + // --- Golden vectors (independent Python HKDF-SHA256, RFC 5869) --- + const GOLDEN_PUBLIC_INVITE_KEY: &str = + "7f02a8a832a1744adf286676038446dc94762c2c8332650c9ad62a0c870e0751"; + const GOLDEN_PUBLIC_INVITE_LOCATOR: &str = + "33c098d6e4cddc2b8ee98ab6b5182186794c35f5b71391130a49ae3d88588c2c"; + const GOLDEN_PUBLIC_INVITE_SIGNER: &str = + "9154a3a7e4a03e94eaad2f76efeebd43e25ee9df4fbca12454edcee0ef666e8d"; + + // server_root = [7;32], channel id = test_channel_id (0xff,0xfe,..), epoch 1. + const GOLDEN_REKEY_PSEUDONYM: &str = + "3a848655f79a586510e1113131f078aa1ce0ff8dcb74374507e6af07ff49fd24"; + // prior_root = [7;32], community id = [9;32], epoch 1. + const GOLDEN_BASE_REKEY_PSEUDONYM: &str = + "23ced8fd6cad30a21ded43c96bd040311cf20bcfff935453dc0985b41ff660be"; + + const GOLDEN_CHANNEL_PSEUDONYM_EPOCH0: &str = + "d55b9f5fad668887d41d46b7c08ba63725a39d7c86b602c7c36e2f2e0eff8c40"; + const GOLDEN_CHANNEL_PSEUDONYM_EPOCH1: &str = + "050079d9899c85bebf5c73fd777cdd812132d262e3ceec83c847a056dea41293"; + // secret = [7;32], epoch 3; channel scope = test_channel_id, root scope = all-zero. + const GOLDEN_RECIPIENT_CHANNEL_EPOCH3: &str = + "971f69d6a948c79704f8077188cded86bd35c82960e88043ebb2c2c3d60a3b71"; + const GOLDEN_RECIPIENT_SERVERROOT_EPOCH3: &str = + "e50e5d803fd2edc310be8cd7354586d12fcb8e3f30162553be53da1a34a17c46"; + // channel key [0..31], epoch 0x0102030405060708 (proves u64 big-endian). + const GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE: &str = + "cec398094d17688cd127bc609d34fa067331427400b023d0c70ff77fafe17e0b"; +} diff --git a/crates/vector-core/src/concord/v1/edition.rs b/crates/vector-core/src/concord/v1/edition.rs new file mode 100644 index 00000000..67221071 --- /dev/null +++ b/crates/vector-core/src/concord/v1/edition.rs @@ -0,0 +1,348 @@ +//! Real-npub authority editions — the keyless model's authorship + version carrier. +//! +//! An authority change (a Grant, RoleMetadata, RoleOrder, Banlist, ...) is an **inner event signed +//! by the ACTOR's own npub**, carrying the entity id, a per-entity +//! `version`, and the previous edition's hash (`prev_hash`, see [`super::version`]). That inner event +//! lives inside the channel/server-root encryption (the outer wrapper is the usual ephemeral signer, +//! [`super::envelope`]), so authorship is **member-verifiable**: the inner Schnorr signature *is* the +//! proof of who acted, and members check that npub against the roster. +//! +//! This module is the wire encoding of one edition (build + verify + parse). It does NOT decide +//! authorization — the signature proves WHO acted; the roster (§roles) decides WHETHER they were +//! allowed, and [`super::version::fold`] decides which edition is current. + +use super::version; +use crate::stored_event::event_kind; +use nostr_sdk::prelude::*; + +const TAG_SUBKIND: &str = "vsk"; +const TAG_ENTITY: &str = "eid"; +const TAG_EVERSION: &str = "ev"; +const TAG_EPREV: &str = "ep"; +const TAG_VERSION: &str = "v"; +const PROTOCOL_VERSION: &str = "1"; +/// Authority citation tag: `["vac", , , ]`. +/// The "pinned proof" — the grant edition the actor claims their authority under. In the MVP it is a +/// COMPLETENESS floor: a verifier confirms it has synced that exact grant to ≥ the cited version (an +/// un-forked, complete view) before acting, and resolves the actor's actual rank against its current +/// (refuse-downgrade-protected) roster — so a since-demoted actor is dropped there. The full +/// "resolve rank AT the cited version" (block-until-synced re-fetch + a roster-wide snapshot version) is +/// the deferred refinement; today it pins the actor's own grant, not a whole-roster moment. Absent when +/// the OWNER acts (supreme, no grant to cite). +pub const TAG_AUTHORITY_CITATION: &str = "vac"; + +/// The pinned authority an actor claims for an action (mechanism a). Points at the actor's own +/// authorizing edition (their Grant — or a RoleMetadata for a role-position claim) by stable +/// coordinate + the exact version/hash, so the verifier resolves authority against that frozen point, +/// not its own possibly-lagging-or-ahead live roster. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorityCitation { + /// The authorizing edition's entity id (e.g. `grant_locator(community_id, actor)`). + pub entity_id: [u8; 32], + /// The version of that edition the actor claims authority under. + pub version: u64, + /// That edition's [`version::edition_hash`] — pins the exact content, not just the version number. + pub edition_hash: [u8; 32], +} + +impl AuthorityCitation { + /// The signed `vac` tag carrying this citation. + pub fn to_tag(&self) -> Tag { + Tag::custom( + TagKind::Custom(TAG_AUTHORITY_CITATION.into()), + [ + crate::simd::hex::bytes_to_hex_32(&self.entity_id), + self.version.to_string(), + crate::simd::hex::bytes_to_hex_32(&self.edition_hash), + ], + ) + } + + /// Extract the citation from an event's tags, or `None` if absent. A malformed `vac` (bad hex / + /// unparseable version) returns `None` — the verifier then treats the action as uncited (owner-only + /// or rejected), never trusting a corrupt citation. + pub fn from_tags(tags: &Tags) -> Option { + let s = tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 4 && s[0] == TAG_AUTHORITY_CITATION).then(|| (s[1].clone(), s[2].clone(), s[3].clone())) + })?; + let valid_hex = |h: &str| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()); + if !valid_hex(&s.0) || !valid_hex(&s.2) { + return None; + } + Some(AuthorityCitation { + entity_id: crate::simd::hex::hex_to_bytes_32(&s.0), + version: s.1.parse().ok()?, + edition_hash: crate::simd::hex::hex_to_bytes_32(&s.2), + }) + } +} + +/// Build the unsigned inner edition event. Sign it with the ACTOR's real identity keys — that +/// signature is the authorship proof. `entity_id` is the entity's 32-byte id, `prev_hash` is the +/// previous edition's [`version::edition_hash`] (`None` for the first edition), `content` is the +/// entity payload JSON, and `created_at_secs` is the authored time (the version-fold tiebreak). +pub fn build_edition_inner( + author: PublicKey, + vsk: &str, + entity_id: &[u8; 32], + version: u64, + prev_hash: Option<&[u8; 32]>, + content: &str, + created_at_secs: u64, + authority: Option<&AuthorityCitation>, +) -> UnsignedEvent { + let mut tags = vec![ + Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [vsk.to_string()]), + Tag::custom(TagKind::Custom(TAG_ENTITY.into()), [crate::simd::hex::bytes_to_hex_32(entity_id)]), + Tag::custom(TagKind::Custom(TAG_EVERSION.into()), [version.to_string()]), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]; + if let Some(p) = prev_hash { + tags.push(Tag::custom(TagKind::Custom(TAG_EPREV.into()), [crate::simd::hex::bytes_to_hex_32(p)])); + } + // The pinned authority proof: absent when the OWNER signs (supreme), present for a delegated + // admin so verifiers resolve their rank at the cited grant version. Outside the version-chain + // self_hash (it's per-action metadata, not chain identity), but covered by the inner signature. + if let Some(a) = authority { + tags.push(a.to_tag()); + } + EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .build(author) +} + +/// A signature-verified, parsed edition. +#[derive(Clone, Debug)] +pub struct ParsedEdition { + /// The real npub that signed (and is thus accountable for) this edition. + pub author: PublicKey, + pub vsk: String, + pub entity_id: [u8; 32], + pub version: u64, + pub prev_hash: Option<[u8; 32]>, + pub content: String, + /// [`version::edition_hash`] of this edition — what the next edition's `prev_hash` must cite. + pub self_hash: [u8; 32], + pub created_at: u64, + pub inner_id: [u8; 32], + /// The pinned authority proof, if the actor cited one. `None` when the OWNER signs (supreme) + /// or a non-authority edition carries no citation. Verified separately against the roster (#3c). + pub authority: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum EditionError { + BadSignature, + MissingField(&'static str), + BadField(&'static str), +} + +fn decode_hash(hex: &str, field: &'static str) -> Result<[u8; 32], EditionError> { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(EditionError::BadField(field)); + } + Ok(crate::simd::hex::hex_to_bytes_32(hex)) +} + +/// Verify + parse an inner edition event. Checks the inner Schnorr signature (the real-npub +/// authorship proof) and extracts the edition fields, computing `self_hash` over the canonical +/// edition bytes. Does NOT check roster authorization — that is the caller's separate step. +pub fn parse_edition_inner(inner: &Event) -> Result { + inner.verify().map_err(|_| EditionError::BadSignature)?; + // Reject duplicate authority tags: the signature covers all of them, but if two clients picked a + // different duplicate they would compute a different `self_hash` for the same signed event and + // diverge on the chain. The map signed-event → canonical bytes must be total and unambiguous. + for name in [TAG_SUBKIND, TAG_ENTITY, TAG_EVERSION, TAG_EPREV, TAG_AUTHORITY_CITATION] { + let count = inner + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str() == name).unwrap_or(false)) + .count(); + if count > 1 { + return Err(EditionError::BadField("duplicate authority tag")); + } + } + let get = |name: &str| -> Option { + inner.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == name).then(|| s[1].clone()) + }) + }; + let vsk = get(TAG_SUBKIND).ok_or(EditionError::MissingField("vsk"))?; + let entity_id = decode_hash(&get(TAG_ENTITY).ok_or(EditionError::MissingField("eid"))?, "eid")?; + let version: u64 = get(TAG_EVERSION) + .ok_or(EditionError::MissingField("ev"))? + .parse() + .map_err(|_| EditionError::BadField("ev"))?; + let prev_hash = match get(TAG_EPREV) { + Some(h) => Some(decode_hash(&h, "ep")?), + None => None, + }; + let content = inner.content.clone(); + let self_hash = version::edition_hash(&entity_id, version, prev_hash.as_ref(), content.as_bytes()); + Ok(ParsedEdition { + author: inner.pubkey, + vsk, + entity_id, + version, + prev_hash, + content, + self_hash, + created_at: inner.created_at.as_secs(), + inner_id: inner.id.to_bytes(), + authority: AuthorityCitation::from_tags(&inner.tags), + }) +} + +impl ParsedEdition { + /// The [`version::Edition`] view used by [`version::fold`]. + pub fn to_fold_edition(&self) -> version::Edition { + version::Edition { + version: self.version, + prev_hash: self.prev_hash, + self_hash: self.self_hash, + created_at: self.created_at, + tiebreak_id: self.inner_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VSK_GRANT: &str = "3"; + + fn eid() -> [u8; 32] { + [0x42; 32] + } + + #[test] + fn round_trips_authorship_version_and_chain_hash() { + let actor = Keys::generate(); + let prev = version::edition_hash(&eid(), 1, None, b"{}"); + let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 2, Some(&prev), "{\"role_ids\":[]}", 1_700_000_000, None) + .sign_with_keys(&actor) + .unwrap(); + + let parsed = parse_edition_inner(&inner).expect("valid edition parses"); + assert_eq!(parsed.author, actor.public_key(), "authorship = the real signer"); + assert_eq!(parsed.vsk, VSK_GRANT); + assert_eq!(parsed.entity_id, eid()); + assert_eq!(parsed.version, 2); + assert_eq!(parsed.prev_hash, Some(prev)); + assert_eq!(parsed.created_at, 1_700_000_000); + // self_hash matches the canonical recomputation (what the next edition will cite). + assert_eq!( + parsed.self_hash, + version::edition_hash(&eid(), 2, Some(&prev), b"{\"role_ids\":[]}") + ); + // Folds into a version::Edition cleanly. + let fe = parsed.to_fold_edition(); + assert_eq!(fe.version, 2); + assert_eq!(fe.prev_hash, Some(prev)); + } + + #[test] + fn authority_citation_round_trips_on_an_edition() { + // A delegated admin's edition carries the pinned authority citation; it survives sign→parse, + // covered by the inner signature, and does NOT alter the chain self_hash (per-action metadata). + let actor = Keys::generate(); + let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] }; + let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, Some(&cite)) + .sign_with_keys(&actor) + .unwrap(); + let parsed = parse_edition_inner(&inner).unwrap(); + assert_eq!(parsed.authority.as_ref(), Some(&cite), "citation round-trips"); + // self_hash is over (entity, version, prev, content) only — the citation doesn't perturb it. + assert_eq!(parsed.self_hash, version::edition_hash(&eid(), 1, None, b"{}")); + + // An uncited edition (owner-signed) parses with authority == None. + let owner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, None) + .sign_with_keys(&actor) + .unwrap(); + assert_eq!(parse_edition_inner(&owner).unwrap().authority, None); + } + + #[test] + fn authority_citation_tag_layout_is_frozen() { + // FROZEN wire layout: the citation rides as a 4-element `vac` tag + // `["vac", , , ]`. A change here reshuffles how every + // verifier reads pinned authority, so pin the exact shape (not just a round-trip). + let cite = AuthorityCitation { entity_id: [0x11; 32], version: 9, edition_hash: [0x22; 32] }; + let tag = cite.to_tag(); + let s = tag.as_slice(); + assert_eq!(s.len(), 4, "vac is a 4-element tag"); + assert_eq!(s[0], TAG_AUTHORITY_CITATION); + assert_eq!(s[1], "11".repeat(32), "entity id is lowercase hex"); + assert_eq!(s[2], "9", "version is the decimal string"); + assert_eq!(s[3], "22".repeat(32), "edition hash is lowercase hex"); + } + + #[test] + fn genesis_edition_has_no_prev() { + let actor = Keys::generate(); + let inner = build_edition_inner(actor.public_key(), "1", &eid(), 1, None, "{}", 100, None) + .sign_with_keys(&actor) + .unwrap(); + let parsed = parse_edition_inner(&inner).unwrap(); + assert_eq!(parsed.prev_hash, None, "first edition cites no predecessor"); + assert_eq!(parsed.version, 1); + } + + #[test] + fn tampered_content_fails_verification() { + // Re-sign integrity: flipping the content after signing breaks the inner Schnorr sig. + let actor = Keys::generate(); + let inner = build_edition_inner(actor.public_key(), "3", &eid(), 1, None, "{\"a\":1}", 100, None) + .sign_with_keys(&actor) + .unwrap(); + let mut json: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap(); + json["content"] = serde_json::Value::String("{\"a\":2}".into()); // tamper + let tampered: Event = serde_json::from_value(json).unwrap(); + assert!(matches!(parse_edition_inner(&tampered), Err(EditionError::BadSignature))); + } + + #[test] + fn missing_required_field_is_rejected_not_panicked() { + // An inner event lacking the entity-id tag is a parse error, never a panic. + let actor = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}") + .tags([Tag::custom(TagKind::Custom("vsk".into()), ["3".to_string()])]) + .sign_with_keys(&actor) + .unwrap(); + assert!(matches!(parse_edition_inner(&inner), Err(EditionError::MissingField("eid")))); + } + + #[test] + fn duplicate_authority_tag_is_rejected() { + // A duplicate of ANY of the 5 authority tags (vsk/eid/ev/ep/vac) makes signed-event → canonical + // bytes ambiguous (clients could pick different ones) → chain divergence, so it must be rejected. + // Parameterized across all 5 — a regression dropping any one from the dedup loop is caught. + let actor = Keys::generate(); + let hash = crate::simd::hex::bytes_to_hex_32(&[0xAB; 32]); + let base = || -> Vec { + vec![ + Tag::custom(TagKind::Custom("vsk".into()), ["1".to_string()]), + Tag::custom(TagKind::Custom("eid".into()), [crate::simd::hex::bytes_to_hex_32(&eid())]), + Tag::custom(TagKind::Custom("ev".into()), ["1".to_string()]), + Tag::custom(TagKind::Custom("ep".into()), [hash.clone()]), + Tag::custom(TagKind::Custom("vac".into()), [crate::simd::hex::bytes_to_hex_32(&eid()), "1".to_string(), hash.clone()]), + ] + }; + let build = |tags: Vec| EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}") + .tags(tags).sign_with_keys(&actor).unwrap(); + assert!(parse_edition_inner(&build(base())).is_ok(), "a clean 5-tag base edition parses"); + for name in ["vsk", "eid", "ev", "ep", "vac"] { + let mut tags = base(); + let dup = tags.iter().find(|t| t.as_slice().first().map(|s| s == name).unwrap_or(false)).cloned().unwrap(); + tags.push(dup); + assert!( + matches!(parse_edition_inner(&build(tags)), Err(EditionError::BadField("duplicate authority tag"))), + "a duplicate `{name}` tag must be rejected" + ); + } + } +} diff --git a/crates/vector-core/src/concord/v1/envelope.rs b/crates/vector-core/src/concord/v1/envelope.rs new file mode 100644 index 00000000..f3eda54d --- /dev/null +++ b/crates/vector-core/src/concord/v1/envelope.rs @@ -0,0 +1,907 @@ +//! Message envelope (GROUP_PROTOCOL.md). +//! +//! A Community message is an inner Nostr event signed by the author's real key +//! (the intra-group authorship proof), NIP-44-v2-encrypted under the shared channel +//! key, and wrapped in an ephemeral-signed outer event tagged with the per-epoch +//! pseudonym. Single NIP-44 pass, O(1) broadcast — not gift wrap's per-recipient +//! double-wrap. +//! +//! `open_message` enforces the binding triad: inner Schnorr signature valid, and +//! inner `kind`/`channel`/`epoch` equal to the outer kind and to the *specific* +//! channel/epoch whose key decrypted the payload (strict equality, never a +//! membership test). That defeats insider replay/splice across type, channel, or +//! epoch — the threat that any member, holding the channel key, could otherwise lift +//! another member's signed content into a different context. + +use nostr_sdk::prelude::*; + +use super::cipher; +use super::derive::channel_pseudonym; +use super::{ChannelId, ChannelKey, Epoch}; +use crate::stored_event::event_kind; + +/// Outer protocol-version tag value (forward-compat hook #2). Checked before any +/// decryption so an unknown version is rejected gracefully. +const PROTOCOL_VERSION: &str = "1"; + +const TAG_VERSION: &str = "v"; +const TAG_CHANNEL: &str = "channel"; +const TAG_EPOCH: &str = "epoch"; +const TAG_MS: &str = "ms"; + +/// Errors from sealing or opening a Community message envelope. +#[derive(Debug)] +pub enum EnvelopeError { + Sign(String), + Encrypt(String), + Decrypt(String), + InnerParse(String), + /// Outer `v` tag is absent or names a version we don't speak. + BadVersion(Option), + KindMismatch { outer: u16, inner: u16 }, + /// Inner channel id ≠ the channel whose key decrypted this (cross-channel splice). + ChannelMismatch, + /// Inner epoch ≠ the epoch whose key decrypted this (cross-epoch splice/replay). + EpochMismatch, + /// Inner author signature failed to verify. + BadSignature, + MissingTag(&'static str), + /// A binding tag appears more than once — the inner event is ambiguous, so the + /// wire form isn't deterministic. Any channel-key holder can craft the inner + /// event, so we reject rather than trust first-match. + DuplicateTag(&'static str), + /// The outer `z` pseudonym matches none of the member's held epoch keys (an epoch we were never a + /// recipient of, or a foreign channel). Not ours to read — dropped, not an error condition. + NoHeldEpoch, +} + +impl std::fmt::Display for EnvelopeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnvelopeError::Sign(e) => write!(f, "sign: {e}"), + EnvelopeError::Encrypt(e) => write!(f, "encrypt: {e}"), + EnvelopeError::Decrypt(e) => write!(f, "decrypt: {e}"), + EnvelopeError::InnerParse(e) => write!(f, "inner parse: {e}"), + EnvelopeError::BadVersion(v) => write!(f, "unsupported protocol version: {v:?}"), + EnvelopeError::KindMismatch { outer, inner } => { + write!(f, "kind mismatch: outer {outer} != inner {inner}") + } + EnvelopeError::ChannelMismatch => write!(f, "channel-binding mismatch (splice)"), + EnvelopeError::EpochMismatch => write!(f, "epoch-binding mismatch (splice/replay)"), + EnvelopeError::BadSignature => write!(f, "inner author signature invalid"), + EnvelopeError::MissingTag(t) => write!(f, "missing inner tag: {t}"), + EnvelopeError::DuplicateTag(t) => write!(f, "duplicate inner tag: {t}"), + EnvelopeError::NoHeldEpoch => write!(f, "no held epoch key for this pseudonym"), + } + } +} + +impl std::error::Error for EnvelopeError {} + +/// A successfully opened and fully-verified Community message. +#[derive(Debug, Clone)] +pub struct OpenedMessage { + /// Inner event id — the `message_id`, the dedup/display key. + pub message_id: EventId, + /// Verified real author. + pub author: PublicKey, + pub content: String, + pub channel_id: ChannelId, + pub epoch: Epoch, + /// Ordering timestamp (epoch ms) via the SHARED `rumor::resolve_message_timestamp` (ms convention + + /// clamp). Kept here because the transport sorts fetched events by it before they become + /// `Message`s; reply-ref + emoji parsing, by contrast, is solely `process_rumor`'s job off `tags`. + pub ms: Option, + /// Inner event's real send time (NOT randomized). + pub created_at: Timestamp, + /// Append-plane sub-kind: 3300 message, 3301 reaction, 3302 edit. + pub kind: u16, + /// File attachments (one per NIP-92 `imeta` tag). A Community message can carry a + /// caption (`content`) plus N attachments in the same event — see `attachments` module. + pub attachments: Vec, + /// The authority citation (`vac` tag), if the inner carried one — present on a non-owner + /// moderation-hide (3305) naming the grant the hider claims authority under. The hide consumer + /// (`apply_delete`) resolves it against the persisted grant head (version-pinned authority). + pub citation: Option, + /// The OUTER wire event id (the relay-addressable event that carried this inner). The + /// transport's dedup key — persisted as the inner's `wrapper_event_id` so a re-fetched + /// channel page skips it pre-decryption, exactly as a DM gift-wrap id does. + pub wrapper_id: EventId, + /// The verified inner event's raw tags — handed to the SHARED `process_rumor` content parser so + /// reply/emoji/ms parsing is identical across transports (the binding tags were already checked). + pub tags: Tags, +} + +/// Seal a plaintext message into an outer wire event. The outer event is signed by +/// a fresh one-time key (no persistent author↔channel linkage on the wire). +/// +/// This convenience form discards that key, so the resulting message is NOT +/// self-deletable. **The product send path should use +/// [`seal_message_with_ephemeral`] and RETAIN the key** (like Vector's +/// `nip17_wrap_keys` for DMs) so the sender can later NIP-09-delete their own +/// message. Ephemeral-on-the-wire and retained-locally are complementary: the +/// relay still sees only one-time keys (no corpus-wide deletion authority), while +/// the sender keeps a per-message key to delete just their own. +pub fn seal_message( + author_keys: &Keys, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, + content: &str, + ms: u64, +) -> Result { + seal_message_with_ephemeral(&Keys::generate(), author_keys, channel_key, channel_id, epoch, content, ms) +} + +/// Like [`seal_message`] but the caller supplies (and may retain) the ephemeral +/// outer-signing key. Retaining it enables a later NIP-09 deletion of this exact +/// outer event (the deletion must be signed by the same key — deletable +/// messages), which is also how on-relay tests clean up after themselves. +pub fn seal_message_with_ephemeral( + ephemeral: &Keys, + author_keys: &Keys, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, + content: &str, + ms: u64, +) -> Result { + // Local-keys convenience: build the inner event, sign it with the author's keys, and + // seal. Identical wire output to the signer path (golden-vector stable). + let inner = build_inner_event(author_keys.public_key(), channel_id, epoch, content, ms, None) + .sign_with_keys(author_keys) + .map_err(|e| EnvelopeError::Sign(e.to_string()))?; + seal_with_signed_inner(ephemeral, &inner, channel_key, channel_id, epoch) +} + +/// Build the inner authorship-proof event UNSIGNED, so the caller can sign it with +/// whatever the account uses — local keys OR a NIP-46 remote bunker (parity with the +/// DM send path, which signs through the active `NostrSigner`). `author` is the +/// identity pubkey the signer will sign as. +/// +/// `ms` is the full send time in epoch-milliseconds, split the way Vector's DMs do: +/// `created_at` carries the seconds, the `ms` tag carries only the sub-second offset +/// (0..999). The open side reconstructs `created_at*1000 + ms`. +pub fn build_inner_event( + author: PublicKey, + channel_id: &ChannelId, + epoch: Epoch, + content: &str, + ms: u64, + reply_to: Option<&str>, +) -> UnsignedEvent { + build_inner_typed(author, channel_id, epoch, event_kind::COMMUNITY_MESSAGE, content, ms, reply_to, &[]) +} + +/// Like [`build_inner_event`] but for any append-plane sub-type — a reaction (3301) or +/// edit (3302) as well as a message (3300). The `reference` `e` tag points at the target: +/// the replied-to message (3300), the reacted-to message (3301), or the edited message +/// (3302). The inner kind is mirrored to the outer on seal, and the receiver enforces the +/// binding triad (kind/channel/epoch). +pub fn build_inner_typed( + author: PublicKey, + channel_id: &ChannelId, + epoch: Epoch, + kind: u16, + content: &str, + ms: u64, + reference: Option<&str>, + emoji_tags: &[crate::types::EmojiTag], +) -> UnsignedEvent { + build_inner_full(author, channel_id, epoch, kind, content, ms, reference, emoji_tags, &[]) +} + +/// Like [`build_inner_typed`] but also carries a slice of EXTRA inner tags appended verbatim — +/// used for NIP-92 `imeta` attachment tags (a 3300 message mixing a caption with N files, via +/// `attachments::attachment_to_imeta`). They are added before signing, so the inner signature +/// covers them; readers pick out what they need by exact tag name. +pub fn build_inner_full( + author: PublicKey, + channel_id: &ChannelId, + epoch: Epoch, + kind: u16, + content: &str, + ms: u64, + reference: Option<&str>, + emoji_tags: &[crate::types::EmojiTag], + extra_tags: &[Tag], +) -> UnsignedEvent { + let created_secs = ms / 1000; + let ms_offset = ms % 1000; + let mut tags = vec![ + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [channel_id.to_hex()]), + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), [epoch.0.to_string()]), + Tag::custom(TagKind::Custom(TAG_MS.into()), [ms_offset.to_string()]), + ]; + // Target reference: an `e` tag marked "reply" (Vector's DM convention) — the + // replied-to / reacted-to / edited message's inner id. + if let Some(target) = reference.filter(|t| !t.is_empty()) { + tags.push(Tag::custom(TagKind::e(), [target.to_string(), String::new(), "reply".to_string()])); + } + // NIP-30 custom emoji: ["emoji", shortcode, url] for each `:shortcode:` used in the + // content (so custom-emoji messages + reactions render the image, parity with DMs). + for et in emoji_tags { + tags.push(Tag::custom(TagKind::Custom("emoji".into()), [et.shortcode.clone(), et.url.clone()])); + } + // Extra inner tags appended verbatim (NIP-92 `imeta` attachment tags). + tags.extend(extra_tags.iter().cloned()); + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_secs)) + .build(author) +} + +/// Seal an already-signed inner authorship event into the outer wire event. The inner +/// may have been signed by local keys or a remote bunker — this stage is signer-agnostic. +/// Defensively re-checks the binding (kind/channel/epoch) so a caller can never seal +/// an inner that the receiver would then reject as a splice. +pub fn seal_with_signed_inner( + ephemeral: &Keys, + inner: &Event, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, +) -> Result { + // Only the community append-plane sub-kinds (message/reaction/edit + cooperative delete + + // presence + cooperative kick + webxdc peer signal + typing) may be sealed. Rekey (3303) and + // InviteBundle (3304) have their own carriers, so the contiguous 3300..=3302 range is admitted + // alongside the explicit 3305/3306/3309/3310/3311. + let inner_kind = inner.kind.as_u16(); + let allowed = (event_kind::COMMUNITY_MESSAGE..=event_kind::COMMUNITY_EDIT).contains(&inner_kind) + || inner_kind == event_kind::COMMUNITY_DELETE + || inner_kind == event_kind::COMMUNITY_PRESENCE + || inner_kind == event_kind::COMMUNITY_KICK + || inner_kind == event_kind::COMMUNITY_WEBXDC + || inner_kind == event_kind::COMMUNITY_TYPING; + if !allowed { + return Err(EnvelopeError::KindMismatch { + outer: event_kind::COMMUNITY_MESSAGE, + inner: inner_kind, + }); + } + match unique_tag(inner, TAG_CHANNEL)? { + Some(c) if c == channel_id.to_hex() => {} + _ => return Err(EnvelopeError::ChannelMismatch), + } + match unique_tag(inner, TAG_EPOCH)? { + Some(e) if e == epoch.0.to_string() => {} + _ => return Err(EnvelopeError::EpochMismatch), + } + + // Single NIP-44 v2 pass under the raw channel key. + let content_b64 = cipher::seal(channel_key.as_bytes(), inner.as_json().as_bytes()) + .map_err(EnvelopeError::Encrypt)?; + + // Outer event: ephemeral signer (no author↔channel linkage on the wire), tagged + // with the per-epoch pseudonym (relay-filterable `z`) and the version. Outer kind + // mirrors the inner (the binding the receiver enforces). + let pseudonym = channel_pseudonym(channel_key, channel_id, epoch); + EventBuilder::new(Kind::Custom(inner_kind), content_b64) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [pseudonym.to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(ephemeral) + .map_err(|e| EnvelopeError::Sign(e.to_string())) +} + +/// Open and fully verify an outer wire event, given the channel key + the exact +/// channel/epoch coordinate that key belongs to. +pub fn open_message( + outer: &Event, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, +) -> Result { + // Version-check BEFORE attempting decryption (hook #2). + match find_tag(outer, TAG_VERSION).as_deref() { + Some(PROTOCOL_VERSION) => {} + other => return Err(EnvelopeError::BadVersion(other.map(str::to_string))), + } + + let plaintext = cipher::open(channel_key.as_bytes(), &outer.content) + .map_err(EnvelopeError::Decrypt)?; + let json = String::from_utf8(plaintext).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?; + let inner = Event::from_json(&json).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?; + + // Inner author signature (the authorship proof). + inner.verify().map_err(|_| EnvelopeError::BadSignature)?; + + // Binding triad — type, channel, epoch. + if inner.kind.as_u16() != outer.kind.as_u16() { + return Err(EnvelopeError::KindMismatch { + outer: outer.kind.as_u16(), + inner: inner.kind.as_u16(), + }); + } + let inner_channel = unique_tag(&inner, TAG_CHANNEL)?.ok_or(EnvelopeError::MissingTag(TAG_CHANNEL))?; + if inner_channel != channel_id.to_hex() { + return Err(EnvelopeError::ChannelMismatch); + } + let inner_epoch = unique_tag(&inner, TAG_EPOCH)?.ok_or(EnvelopeError::MissingTag(TAG_EPOCH))?; + if inner_epoch != epoch.0.to_string() { + return Err(EnvelopeError::EpochMismatch); + } + + // Reply-ref + emoji parsing is the SHARED parser's job (runs off `tags` in `process_rumor`), so it + // lives in ONE place. The transport keeps only: the ordering `ms` (used to sort fetched events + // before they're parsed — via the SAME shared resolver), NIP-92 `imeta` attachments, and the + // authority citation. + let ms = Some(crate::rumor::resolve_message_timestamp( + inner.created_at.as_secs(), + unique_tag(&inner, TAG_MS)?.as_deref(), + )); + let attachments = super::attachments::attachments_from_tags( + inner.tags.iter(), + &crate::db::get_download_dir(), + ); + Ok(OpenedMessage { + message_id: inner.id, + author: inner.pubkey, + content: inner.content.clone(), + channel_id: *channel_id, + epoch, + ms, + created_at: inner.created_at, + kind: inner.kind.as_u16(), + attachments, + citation: super::edition::AuthorityCitation::from_tags(&inner.tags), + wrapper_id: outer.id, + tags: inner.tags.clone(), + }) +} + +/// Open an outer wire event when the member may hold MULTIPLE epoch keys (post-rekey catch-up): select +/// the decryption key by the outer's `z` pseudonym tag — each epoch addresses a distinct pseudonym we +/// can recompute — then open under that exact epoch. `epoch_keys` is the member's retained `(epoch, key)` +/// set for this channel. A `z` matching no held epoch yields [`EnvelopeError::NoHeldEpoch`] (not ours to +/// read), keeping the per-event cost one pseudonym derivation per held epoch (a handful), no trial-decrypt. +pub fn open_message_multi( + outer: &Event, + channel_id: &ChannelId, + epoch_keys: &[(Epoch, ChannelKey)], +) -> Result { + let z = find_tag(outer, "z").ok_or(EnvelopeError::MissingTag("z"))?; + for (epoch, key) in epoch_keys { + if channel_pseudonym(key, channel_id, *epoch).to_hex() == z { + return open_message(outer, key, channel_id, *epoch); + } + } + Err(EnvelopeError::NoHeldEpoch) +} + +/// First value of the first tag named `name` (for outer routing tags like `v`, +/// which the ephemeral signer controls and which carry no binding weight). +fn find_tag(event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == name).then(|| s[1].clone()) + }) +} + +/// Value of the tag named `name`, requiring it to appear AT MOST ONCE. The inner +/// event is constructable by any channel-key holder, so a duplicated binding tag +/// would make first-match nondeterministic — reject it. +fn unique_tag(event: &Event, name: &'static str) -> Result, EnvelopeError> { + let mut found: Option = None; + for t in event.tags.iter() { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == name { + if found.is_some() { + return Err(EnvelopeError::DuplicateTag(name)); + } + found = Some(s[1].clone()); + } + } + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> ChannelKey { + ChannelKey([0x11u8; 32]) + } + fn chan() -> ChannelId { + ChannelId([0xaau8; 32]) + } + + fn channel_tag() -> Tag { + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [chan().to_hex()]) + } + fn epoch0_tag() -> Tag { + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), ["0".to_string()]) + } + + /// Seal an arbitrary inner event into a correctly-tagged outer (epoch 0). + fn wrap_inner(inner: &Event) -> Event { + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn round_trip() { + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "gm fren", 1_700_000_000_000) + .expect("seal"); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open"); + assert_eq!(opened.content, "gm fren"); + assert_eq!(opened.author, author.public_key()); + assert_eq!(opened.ms, Some(1_700_000_000_000)); + assert_eq!(opened.channel_id, chan()); + assert_eq!(opened.epoch, Epoch(0)); + } + + #[tokio::test] + async fn signer_path_matches_local_keys_path() { + // Parity with DMs: the inner event can be signed through the async + // `NostrSigner` (the same path a NIP-46 bunker uses) instead of local keys. + // `Keys` implements `NostrSigner`, so signing the unsigned inner via `.sign()` + // exercises that path; the sealed result must open identically. + let author = Keys::generate(); + let ephemeral = Keys::generate(); + + let unsigned = build_inner_event(author.public_key(), &chan(), Epoch(0), "via signer", 1_700_000_000_777, None); + let inner: Event = unsigned.sign(&author).await.expect("remote-style sign"); + let outer = seal_with_signed_inner(&ephemeral, &inner, &key(), &chan(), Epoch(0)).expect("seal"); + + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open"); + assert_eq!(opened.content, "via signer"); + assert_eq!(opened.author, author.public_key(), "authorship is the identity key, not ephemeral"); + assert_eq!(opened.ms, Some(1_700_000_000_777)); + // Retained ephemeral is the outer signer (so it's still self-deletable). + assert_eq!(outer.pubkey, ephemeral.public_key()); + } + + #[test] + fn reply_reference_round_trips() { + // A reply target (inner `e` tag) survives seal→open and surfaces on the parsed Message via the + // shared parser (reply parsing now lives in `process_rumor`, exercised end-to-end via build_message). + let author = Keys::generate(); + let target = "a".repeat(64); + let inner = build_inner_event(author.public_key(), &chan(), Epoch(0), "re: hi", 5, Some(&target)) + .sign_with_keys(&author) + .unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key()); + assert_eq!(msg.replied_to, target); + + // No reply target → replied_to is empty. + let plain = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 6).unwrap(); + let opened2 = open_message(&plain, &key(), &chan(), Epoch(0)).unwrap(); + assert!(crate::community::inbound::build_message(&opened2, &Keys::generate().public_key()).replied_to.is_empty()); + } + + #[test] + fn custom_emoji_tags_round_trip() { + // NIP-30 `["emoji", shortcode, url]` tags survive seal→open and surface on the parsed Message + // (shared parser), so custom-emoji messages render the image — parity with DMs. + let author = Keys::generate(); + let tags = vec![crate::types::EmojiTag { + shortcode: "fire".into(), + url: "https://blossom/fire.png".into(), + }]; + let inner = build_inner_typed( + author.public_key(), &chan(), Epoch(0), + crate::stored_event::event_kind::COMMUNITY_MESSAGE, "gm :fire:", 1, None, &tags, + ) + .sign_with_keys(&author) + .unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key()); + assert_eq!(msg.emoji_tags.len(), 1); + assert_eq!(msg.emoji_tags[0].shortcode, "fire"); + assert_eq!(msg.emoji_tags[0].url, "https://blossom/fire.png"); + } + + #[test] + fn far_future_inner_timestamp_is_clamped() { + // W3: the inner created_at isn't relay-clamped (only the outer is published), so a + // hostile member could stamp it absurdly far in the future to pin the message to + // the top forever. open_message clamps an implausible ms back to receipt time. + let author = Keys::generate(); + let far_future = 99_999_999_999_999u64; // ~year 5138 in epoch-ms + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "from the future", far_future).unwrap(); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert!(opened.ms.unwrap() < far_future, "far-future ordering ms must be clamped to ~now"); + } + + #[test] + fn duplicate_reply_tag_on_a_message_is_tolerated() { + // A message's reply pointer is cosmetic, so a duplicate `e` no longer drops the whole message — + // the content is preserved (open succeeds). The security-critical disambiguation moved to the + // SHARED parser, which rejects an ambiguous TARGET for reactions/edits/deletes + // (see `rumor::unique_event_ref`); that's covered by the rumor-level tests. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .tags([ + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [chan().to_hex()]), + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), ["0".to_string()]), + Tag::custom(TagKind::Custom(TAG_MS.into()), ["1".to_string()]), + Tag::custom(TagKind::e(), ["aa".repeat(32), String::new(), "reply".to_string()]), + Tag::custom(TagKind::e(), ["bb".repeat(32), String::new(), "reply".to_string()]), + ]) + .sign_with_keys(&author) + .unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + assert!(open_message(&outer, &key(), &chan(), Epoch(0)).is_ok(), + "a message must not be dropped over an ambiguous (cosmetic) reply pointer"); + } + + #[test] + fn multi_attachment_message_round_trips_caption_and_imeta() { + // The protocol's multi-attachment capability: ONE event carries a caption + // (content) plus N attachments (one imeta each), optionally replying. Verify the + // full seal → open path reconstructs the caption, the reply target, and every + // attachment's crypto + metadata in order. + use crate::types::{Attachment, ImageMetadata}; + let mk = |name: &str, ext: &str, img: bool| Attachment { + id: "x".into(), + key: "0".repeat(64), + nonce: format!("{:0<24}", name), + extension: ext.into(), + name: name.into(), + url: format!("https://blossom.example/{name}"), + path: String::new(), + size: 1234, + img_meta: img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 64, height: 48 }), + downloading: false, + downloaded: false, + webxdc_topic: None, + group_id: None, + original_hash: Some("a".repeat(64)), + scheme_version: None, + mls_filename: None, + }; + let imetas = vec![ + super::super::attachments::attachment_to_imeta(&mk("photo.png", "png", true)), + super::super::attachments::attachment_to_imeta(&mk("notes.pdf", "pdf", false)), + ]; + let author = Keys::generate(); + let reply_target = "bb".repeat(32); + let inner = build_inner_full( + author.public_key(), &chan(), Epoch(0), + event_kind::COMMUNITY_MESSAGE, "look at these", 1_700_000_000_123, + Some(&reply_target), &[], &imetas, + ).sign_with_keys(&author).unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert_eq!(opened.content, "look at these"); + // Reply ref is parsed by the shared parser; the built Message carries it. Attachments are the + // transport-specific imeta, still parsed at open. + let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key()); + assert_eq!(msg.replied_to, reply_target); + assert_eq!(opened.attachments.len(), 2, "both attachments parse"); + assert_eq!(opened.attachments[0].name, "photo.png"); + assert_eq!(opened.attachments[0].key, "0".repeat(64)); + assert_eq!(opened.attachments[0].extension, "png"); + assert!(opened.attachments[0].img_meta.is_some(), "image carries thumbhash/dim"); + assert!(opened.attachments[0].group_id.is_none(), "Community attachment uses explicit key/nonce"); + assert_eq!(opened.attachments[1].name, "notes.pdf"); + assert_eq!(opened.attachments[1].extension, "pdf"); + assert!(opened.attachments[1].img_meta.is_none()); + // A caption-only message (no imeta) opens with zero attachments. + let plain = seal_message(&author, &key(), &chan(), Epoch(0), "just text", 1).unwrap(); + assert!(open_message(&plain, &key(), &chan(), Epoch(0)).unwrap().attachments.is_empty()); + } + + #[test] + fn seal_rejects_inner_bound_to_wrong_channel() { + // seal_with_signed_inner must refuse an inner whose binding doesn't match the + // coordinate being sealed under (can't produce an unopenable/spliced message). + let author = Keys::generate(); + let other = ChannelId([0xbbu8; 32]); + let inner = build_inner_event(author.public_key(), &other, Epoch(0), "x", 1, None) + .sign_with_keys(&author) + .unwrap(); + let err = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}"); + } + + #[test] + fn ms_splits_to_created_at_and_offset_and_reconstructs() { + // Full epoch-ms in → created_at(secs) + ms-offset(0..999) on the wire → + // exact full ms back out (lossless, and matches Vector's DM convention). + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "ts", 1_234_567).unwrap(); + // On the wire: created_at is seconds, the ms tag is only the 0..999 offset. + assert_eq!(outer_inner_created_at_secs(&outer), 1234); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert_eq!(opened.ms, Some(1_234_567), "full ms reconstructed from secs*1000 + offset"); + assert_eq!(opened.created_at.as_secs(), 1234); + } + + /// Decrypt + read the inner event's created_at (test helper). + fn outer_inner_created_at_secs(outer: &Event) -> u64 { + let pt = cipher::open(key().as_bytes(), &outer.content).unwrap(); + let inner = Event::from_json(&String::from_utf8(pt).unwrap()).unwrap(); + inner.created_at.as_secs() + } + + #[test] + fn outer_signer_is_ephemeral_not_author() { + // The wire event must NOT be signed by the author's real key (no linkage). + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 1).unwrap(); + assert_ne!( + outer.pubkey, + author.public_key(), + "outer event must be ephemeral-signed, not author-signed" + ); + // ...but the recovered author (inner) IS the real key. + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert_eq!(opened.author, author.public_key()); + } + + #[test] + fn identical_plaintext_yields_distinct_ciphertext() { + let author = Keys::generate(); + let a = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap(); + let b = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap(); + assert_ne!(a.content, b.content, "per-message nonce must randomize ciphertext"); + } + + #[test] + fn wrong_key_is_rejected() { + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "secret", 1).unwrap(); + let wrong = ChannelKey([0x22u8; 32]); + let err = open_message(&outer, &wrong, &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}"); + } + + #[test] + fn cross_channel_splice_is_rejected() { + // Decrypt succeeds under a key we hold, but the inner channel tag names a + // different channel than the key's channel → strict-equality check fires. + let author = Keys::generate(); + // Seal for channel A. + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "for A", 1).unwrap(); + // Attempt to open as if it belonged to a DIFFERENT channel B, using the SAME + // key (simulating a member who re-published it under B's coordinate). + let chan_b = ChannelId([0xbbu8; 32]); + let err = open_message(&outer, &key(), &chan_b, Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}"); + } + + #[test] + fn cross_epoch_splice_is_rejected() { + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "epoch 0", 1).unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(1)); + assert!(matches!(err, Err(EnvelopeError::EpochMismatch)), "got {err:?}"); + } + + #[test] + fn tampered_ciphertext_is_rejected() { + // Flip a byte of the base64 payload → NIP-44 MAC must fail on decrypt. + let author = Keys::generate(); + let mut outer = + seal_message(&author, &key(), &chan(), Epoch(0), "integrity", 1).unwrap(); + // Rebuild an outer event with a corrupted content (events are immutable, so + // re-sign a mutated copy with a fresh ephemeral key). + let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0xff; + let corrupted = base64_simd::STANDARD.encode_to_string(&bytes); + let ephemeral = Keys::generate(); + outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), corrupted) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&ephemeral) + .unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}"); + } + + #[test] + fn missing_version_tag_is_rejected() { + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .sign_with_keys(&author) + .unwrap(); + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + let ephemeral = Keys::generate(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .sign_with_keys(&ephemeral) + .unwrap(); + assert!(matches!( + open_message(&outer, &key(), &chan(), Epoch(0)), + Err(EnvelopeError::BadVersion(None)) + )); + } + + #[test] + fn two_members_exchange() { + // The e2e seed: Alice and Bob hold the same channel key; each can open the + // other's sealed message and recover the correct real author. + let alice = Keys::generate(); + let bob = Keys::generate(); + let shared = key(); + + let from_alice = seal_message(&alice, &shared, &chan(), Epoch(0), "yo bob", 10).unwrap(); + let seen_by_bob = open_message(&from_alice, &shared, &chan(), Epoch(0)).unwrap(); + assert_eq!(seen_by_bob.author, alice.public_key()); + assert_eq!(seen_by_bob.content, "yo bob"); + + let from_bob = seal_message(&bob, &shared, &chan(), Epoch(0), "hey alice", 11).unwrap(); + let seen_by_alice = open_message(&from_bob, &shared, &chan(), Epoch(0)).unwrap(); + assert_eq!(seen_by_alice.author, bob.public_key()); + assert_eq!(seen_by_alice.content, "hey alice"); + + // message_ids differ (distinct inner events). + assert_ne!(seen_by_bob.message_id, seen_by_alice.message_id); + } + + #[test] + fn unknown_version_is_rejected_before_decrypt() { + // Hand-build an outer event with a bogus version tag; must reject on version, + // never reaching decryption. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .sign_with_keys(&author) + .unwrap(); + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + let ephemeral = Keys::generate(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["999".to_string()])]) + .sign_with_keys(&ephemeral) + .unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "999"), "got {err:?}"); + } + + #[test] + fn inner_kind_mismatch_is_rejected() { + // A signed REACTION inner re-wrapped inside a MESSAGE outer → type splice. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REACTION), "+") + .tags([channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let outer = wrap_inner(&inner); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!( + matches!(err, Err(EnvelopeError::KindMismatch { outer: 3300, inner: 3301 })), + "got {err:?}" + ); + } + + #[test] + fn forged_inner_signature_is_rejected() { + // Tamper the inner content after signing: id/sig no longer verify. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "real") + .tags([channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap(); + v["content"] = serde_json::Value::String("forged".into()); + let tampered = serde_json::to_string(&v).unwrap(); + let content = cipher::seal(key().as_bytes(), tampered.as_bytes()).unwrap(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::BadSignature)), "got {err:?}"); + } + + #[test] + fn missing_channel_tag_is_rejected() { + // Inner has a valid sig + matching kind + epoch, but no channel tag. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi") + .tags([epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let outer = wrap_inner(&inner); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::MissingTag(t)) if t == TAG_CHANNEL), "got {err:?}"); + } + + #[test] + fn duplicate_channel_tag_is_rejected() { + // Two channel tags → ambiguous inner; must reject (don't trust first-match). + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi") + .tags([channel_tag(), channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let outer = wrap_inner(&inner); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::DuplicateTag(t)) if t == TAG_CHANNEL), "got {err:?}"); + } + + #[test] + fn version_is_checked_before_decryption() { + // Bogus version AND wrong key: must fail on version, NOT decrypt. This proves + // the ordering — a regression moving the version check after decrypt would + // instead surface a Decrypt error here (wrong key), so this catches it where + // the correct-key version test cannot. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .tags([channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["7".to_string()])]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let wrong_key = ChannelKey([0x99u8; 32]); + let err = open_message(&outer, &wrong_key, &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "7"), "got {err:?}"); + } + + #[test] + fn truncated_ciphertext_is_rejected() { + // Distinct from the bit-flip test: chop bytes off the payload → length/MAC fail. + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "intact", 1).unwrap(); + let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap(); + bytes.truncate(bytes.len().saturating_sub(5)); + let truncated = base64_simd::STANDARD.encode_to_string(&bytes); + let mangled = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), truncated) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(matches!(open_message(&mangled, &key(), &chan(), Epoch(0)), Err(EnvelopeError::Decrypt(_)))); + } + + #[test] + fn seal_uses_matching_inner_and_outer_kind() { + // The binding triad requires inner.kind == outer.kind; seal must produce that. + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap(); + assert_eq!(outer.kind.as_u16(), event_kind::COMMUNITY_MESSAGE); + // Decrypt the inner and confirm its kind mirrors the outer. + let plaintext = cipher::open(key().as_bytes(), &outer.content).unwrap(); + let inner = Event::from_json(&String::from_utf8(plaintext).unwrap()).unwrap(); + assert_eq!(inner.kind.as_u16(), outer.kind.as_u16()); + } + + #[test] + fn seal_tags_outer_with_correct_pseudonym_and_version() { + // the relay-filterable `z` tag must carry the correct epoch pseudonym, + // and `v` must be "1" — a regression here silently breaks relay querying. + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap(); + let expected = super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex(); + assert_eq!(find_tag(&outer, "z").as_deref(), Some(expected.as_str())); + assert_eq!(find_tag(&outer, TAG_VERSION).as_deref(), Some("1")); + } +} diff --git a/crates/vector-core/src/concord/v1/inbound.rs b/crates/vector-core/src/concord/v1/inbound.rs new file mode 100644 index 00000000..fe70dbcb --- /dev/null +++ b/crates/vector-core/src/concord/v1/inbound.rs @@ -0,0 +1,1623 @@ +//! Inbound processing: turn a verified, opened Community message into a `Message` +//! in `STATE` under its channel chat (→ app state). Pure conversion +//! (`build_message`) is separated from the STATE mutation (`ingest_message`) so the +//! conversion is unit-testable without any global state. + +use nostr_sdk::prelude::{Event, PublicKey}; +use nostr_sdk::ToBech32; + +use super::envelope::{open_message_multi, OpenedMessage}; +use super::Channel; +use crate::state::ChatState; +use crate::stored_event::event_kind; +use crate::types::Message; + +/// Convert a verified [`OpenedMessage`] into a STATE `Message` via the SHARED content parser. +/// +/// A Concord 3300 normalizes to a text rumor and runs through `rumor::process_rumor` — the exact same +/// path a NIP-17 DM text message takes — so content, reply ref, emoji, ms (incl. the future-clamp), +/// and author-by-conversation-type are parsed in ONE place for every transport. The only Concord- +/// specific layering is attachments: Concord carries NIP-92 `imeta` (multi-file + caption), already +/// parsed in `open_message`, so they're set on top of the shared text result. +/// Build a normalized `(RumorEvent, RumorContext)` from an opened Concord inner so it can run through +/// the SHARED `rumor::process_rumor`. `kind` is the canonical content kind the sub-kind maps to +/// (3300→14, 3301→reaction, 3302→edit, 3305→deletion). The binding/banlist/authority checks already +/// happened at the transport layer; this is purely the bridge to the shared content parser. +fn concord_rumor( + opened: &OpenedMessage, + kind: nostr_sdk::Kind, + my_pubkey: &PublicKey, +) -> (crate::rumor::RumorEvent, crate::rumor::RumorContext) { + use crate::rumor::{ConversationType, RumorContext, RumorEvent}; + ( + RumorEvent { + id: opened.message_id, + kind, + content: opened.content.clone(), + tags: opened.tags.clone(), + created_at: opened.created_at, + pubkey: opened.author, + }, + RumorContext { + sender: opened.author, + is_mine: opened.author == *my_pubkey, + conversation_id: opened.channel_id.to_hex(), + conversation_type: ConversationType::Community, + }, + ) +} + +pub fn build_message(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Message { + use crate::rumor::{process_rumor, RumorProcessingResult}; + let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::PrivateDirectMessage, my_pubkey); + let mut msg = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) { + Ok(RumorProcessingResult::TextMessage(m)) => m, + // A 3300 is always a caption/text message, so this never fires — but never drop a message on + // a parser quirk: fall back to the minimal direct fields. + _ => Message { + id: opened.message_id.to_hex(), + content: opened.content.clone(), + at: opened.ms.unwrap_or_else(|| opened.created_at.as_secs().saturating_mul(1000)), + mine: opened.author == *my_pubkey, + npub: opened.author.to_bech32().ok(), + ..Default::default() + }, + }; + // Transport-specific: Concord attachments are NIP-92 imeta (already parsed). Link the outer wire + // id for the shared dedup. The shared parser already set content/reply/emoji/ms/npub. + msg.attachments = opened.attachments.clone(); + msg.wrapper_event_id = Some(opened.wrapper_id.to_hex()); + msg +} + +/// Ingest a verified Community message into STATE under its channel chat, creating +/// the chat (as `ChatType::Community`) if absent. Returns the added `Message` (so the +/// caller can persist + emit it), or `None` if it was a duplicate (dedup on the inner +/// message id). +pub fn ingest_message( + state: &mut ChatState, + opened: &OpenedMessage, + my_pubkey: &PublicKey, +) -> Option { + let chat_id = opened.channel_id.to_hex(); + let msg = build_message(opened, my_pubkey); + // DB-level dedup: an inner id already in the events table is KNOWN — don't re-ingest into + // STATE or re-emit it. A boot/catch-up sweep re-fetches the whole channel page, but in-memory + // STATE only holds the per-chat hydration window, so it can't dedup the tail on its own. Without + // this, replayed sends (incl. our own) resurface as "new", re-firing reads/notifications. Mirrors + // the DM pipeline: outer dedup (wrapper-id cache) + inner dedup (events table). Known events live + // in the DB and load from there. + if crate::db::events::event_exists(&msg.id).unwrap_or(false) { + return None; + } + state.ensure_community_chat(&chat_id); + if state.add_message_to_chat(&chat_id, msg.clone()) { + Some(msg) + } else { + None + } +} + +/// The result of processing an inbound wire event: a brand-new message (3300), an update to +/// an existing message (a reaction 3301 / edit 3302 applied to its target), or a tombstone +/// (a delete 3305 that removed its target). New/Updated surface as a UI `message_new` / +/// `message_update`; Removed surfaces as a `message_removed`. +pub enum IncomingEvent { + NewMessage(Message), + /// An existing message changed (reaction or edit). `message` is the live-updated view for the + /// UI. `edit_event` is `Some` only for edits: the `MESSAGE_EDIT` StoredEvent the caller persists + /// (event-sourced, folded on reload like DM edits) instead of overwriting the row. Reactions + /// leave it `None` and the caller re-saves the message row (which carries the new reaction). + Updated { target_id: String, message: Message, edit_event: Option> }, + Removed { target_id: String }, + /// A reaction was revoked by its author (a 3305 tombstone whose target is a reaction id). + /// The caller drops the reaction's kind-7 row and re-emits `message` so chips refresh live. + /// Distinct from `Removed` (whole message) and `Updated` (re-saves the row, which is additive + /// and so can't express a removal). `message_id` is the parent for the UI update. + ReactionRemoved { message_id: String, reaction_id: String, message: Message }, + /// A join/leave presence announcement (kind 3306). `npub` is the announcing member; the + /// caller persists + surfaces it as a `MemberJoined`/`MemberLeft` system event. `event_id` + /// is the inner id (dedup key). `created_at` is the inner's authenticated timestamp (secs) so a + /// HISTORICALLY-synced join/leave lands at the right place in the timeline, not at ingest-time + /// "now". `invited_by`/`invited_label` carry attribution on an invite-join (who/which-link + /// brought them) — `None` for a plain join/leave. Not a message. + Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option, invited_label: Option }, + /// A cooperative kick (3309) targeting THE LOCAL USER, authorized (signer held `KICK` + outranked + /// us). The caller performs the self-removal teardown (wipe local chat data, RETAIN the held + /// epoch keys). A kick of ANOTHER member surfaces as `Presence { joined: false }` + /// instead, so it falls out of the observed member list without a dedicated arm. + Kicked { community_id: String }, + /// A voluntary leave-presence (3306, content "leave") whose inner author IS the local npub — i.e. a + /// leave I (or another of my devices) published. route to the same self-removal teardown as + /// `Kicked`/ban so a leave on device A tears the community down on device B too. Safe because the + /// presence inner is real-npub-signed (only my own devices can author a leave for my npub). The + /// teardown is idempotent, so the publishing device tearing down on its own echoed leave is a no-op. + SelfLeft { community_id: String }, + /// A WebXDC realtime peer signal (3310): a member advertising their Iroh node for a Mini App + /// session (`node_addr` = Some) or announcing they stopped playing (`node_addr` = None). The + /// caller persists it (kind-30078 row keyed by `topic_id`, the DM-parity shape) and — when a + /// realtime channel for the topic is live — feeds the peer to the gossip layer. Not a message. + WebxdcPeer { + npub: String, + topic_id: String, + /// Base32 iroh node address — `Some` for an advertisement, `None` for peer-left. + node_addr: Option, + event_id: String, + created_at: u64, + }, + /// A typing indicator (3311): a member is composing in this channel. Ephemeral — never persisted + /// or folded; the caller feeds it to the live typing tracker and emits `typing-update`. `until` is + /// the unix-secs the typer should stop being shown as active (receiver-computed, ~30s out). + Typing { npub: String, until: u64 }, +} + +/// Open a single incoming wire event against `channel`, verify the binding, and apply +/// it to STATE by sub-kind: a message is ingested, a reaction/edit is applied to its +/// target. Events that fail to open (wrong key, splice, forged sig, bad version) or that +/// dedup/target-miss are dropped (returns `None`). This is the per-event handler the +/// real-time subscription routes each arriving 3300/3301/3302 event through. +pub fn process_incoming( + state: &mut ChatState, + event: &Event, + channel: &Channel, + my_pubkey: &PublicKey, +) -> Option { + // Outer-event dedup, shared with DMs via the cross-transport ledger: a wire event we've already + // processed is either recorded as some inner's `wrapper_event_id` (row-creating sub-kinds) or in + // the `processed_wrappers` ledger (non-row sub-kinds). Skip it BEFORE decryption — the same role + // the wrapper-id cache plays for gift-wraps. The per-inner-id check in ingest_message is the + // backstop for the same inner re-published under a fresh wire event. + let outer_bytes = event.id.to_bytes(); + if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false) + || crate::db::wrappers::processed_wrapper_exists(&outer_bytes) + { + return None; + } + // binary seal: a dissolved community DROPS every subsequent event — control or message, any author + // (owner included), any claimed time. NO timestamp comparison: the seal is the flag, not a time. + // Already-persisted events stay (no retroactive purge); this only stops NEW events from landing. + // CARVE-OUT: own-message DELETIONS (3305) always pass — data ownership means anyone can scrub their own + // content from a dead community, and a delete only removes the author's OWN message (it can't inject, so + // it doesn't reopen the backdating attack the seal exists to stop). `apply_delete` restricts a dissolved + // community's deletes to SELF-deletes (moderation-hides are blocked). + if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE { + return None; + } + // Select the decryption key by the event's epoch pseudonym across ALL held epochs (post-rekey + // catch-up), so a message posted under an older epoch still opens. Falls back to the head epoch for + // send-built/test channels (read_epoch_keys). + let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) { + Ok(o) => o, + Err(e) => { + crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e); + return None; + } + }; + // Banlist (the "anti-memberlist"): drop EVERY event kind from a banned author — message, + // reaction, edit, delete, presence — so a banned member vanishes entirely, presence and all. + if channel.banned.contains(&opened.author) { + crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex()); + return None; + } + let outcome = match opened.kind { + k if k == event_kind::COMMUNITY_MESSAGE => { + ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage) + } + k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey), + k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey), + k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey), + k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey), + k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey), + k if k == event_kind::COMMUNITY_WEBXDC => apply_webxdc(&opened, my_pubkey), + k if k == event_kind::COMMUNITY_TYPING => apply_typing(&opened, my_pubkey), + _ => None, + }; + // Record the outer id in the shared ledger for NON-message sub-kinds, which have no inner row to + // carry a `wrapper_event_id` (messages are covered atomically by that column on save). These are + // idempotent on replay, so recording at process time is safe. Gives every sub-kind the same + // pre-decryption skip on a re-fetch that messages already get. Typing is exempt — it's a frequent, + // realtime-only ephemeral signal; recording every keystroke ping would bloat the ledger for no gain. + if let Some(ref evt) = outcome { + if !matches!(evt, IncomingEvent::NewMessage(_) | IncomingEvent::Typing { .. }) { + let _ = crate::db::wrappers::save_processed_wrapper( + &outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD, + ); + } + } + outcome +} + +/// Interpret a presence announcement (3306). The inner author is the member; content "leave" +/// marks a departure, anything else (e.g. "join") an arrival. No STATE mutation here — the +/// caller turns this into a persisted system event (which is where dedup-by-id happens). +fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option { + // Content is "leave", plain "join", or an attributed-join JSON `{"by":"","l":"