A rust implementation of skywatch-phash

Initial commit, rust reimplementation

Skywatch 447f38e7

+10
.dockerignore
··· 1 + target/ 2 + .env 3 + .env.* 4 + !.env.example 5 + .git/ 6 + .gitignore 7 + *.md 8 + !CLAUDE.md 9 + .DS_Store 10 + firehose_cursor.db
+40
.env.example
··· 1 + # PDS Configuration 2 + PDS_ENDPOINT=https://bsky.social 3 + 4 + # PLC Configuration 5 + PLC_ENDPOINT=https://plc.directory 6 + 7 + # Automod Account Configuration (REQUIRED) 8 + # The automod account has moderator permissions and is used for authentication 9 + # DID is automatically sourced from the agent session after login 10 + AUTOMOD_HANDLE=your-automod.bsky.social 11 + AUTOMOD_PASSWORD=your-app-password-here 12 + 13 + # Ozone Configuration (REQUIRED) 14 + OZONE_URL=https://ozone.skywatch.blue 15 + OZONE_PDS=https://blewit.us-west.host.bsky.network 16 + 17 + # Labeler Configuration (REQUIRED) 18 + # This is the DID of your main labeler account (e.g., skywatch.blue) 19 + # NOT the automod account, NOT the ozone service 20 + LABELER_DID=did:plc:your-labeler-did-here 21 + RATE_LIMIT_MS=100 22 + 23 + # Jetstream Configuration 24 + JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 25 + CURSOR_UPDATE_INTERVAL=10000 26 + 27 + # Redis Configuration 28 + REDIS_URL=redis://localhost:6379 29 + 30 + # Cache Configuration 31 + CACHE_ENABLED=true 32 + CACHE_TTL_SECONDS=86400 33 + 34 + # Processing Configuration 35 + PROCESSING_CONCURRENCY=4 36 + RETRY_ATTEMPTS=3 37 + RETRY_DELAY_MS=1000 38 + 39 + # Phash Configuration 40 + PHASH_HAMMING_THRESHOLD=5
+1
.envrc
··· 1 + use flake
+18
.gitignore
··· 1 + /target 2 + /result 3 + /result-lib 4 + .direnv 5 + .claude 6 + /.pre-commit-config.yaml 7 + CLAUDE.md 8 + AGENTS.md 9 + crates/jacquard-lexicon/target 10 + /plans 11 + /docs 12 + /binaries/releases/ 13 + rustdoc-host.nix 14 + **/**.car 15 + cursor.txt 16 + .session 17 + .env 18 + firehose_cursor.db
+18
.zed/settings.json
··· 1 + // Folder-specific settings 2 + // 3 + // For a full list of overridable settings, and general information on folder-specific settings, 4 + // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 + { 6 + "lsp": { 7 + "rust-analyzer": { 8 + "initialization_options": { 9 + "rust": { 10 + "analyzerTargetDir": true 11 + }, 12 + "cargo": { 13 + "allFeatures": true 14 + } 15 + } 16 + } 17 + } 18 + }
+1018
ARCHITECTURE.md
··· 1 + # Skywatch-Phash: Complete Architecture & Implementation Guide 2 + 3 + ## Overview 4 + 5 + **skywatch-phash** is a real-time perceptual hash-based image moderation service for Bluesky/ATProto. It: 6 + 1. Subscribes to the Bluesky firehose (Jetstream) in real-time 7 + 2. Extracts images from posts and computes perceptual hashes (phash) 8 + 3. Compares against known harmful image hashes using Hamming distance 9 + 4. Automatically applies moderation labels and reports to Bluesky Ozone moderators 10 + 11 + **Tech Stack:** Bun/TypeScript, Redis, Jetstream, ATProto API, Sharp (image processing) 12 + 13 + --- 14 + 15 + ## Architecture Overview 16 + 17 + ``` 18 + ┌─────────────────────────────────────────────────────────────┐ 19 + │ Main Application │ 20 + │ (main.ts - Jetstream event handler + orchestrator) │ 21 + └─────────────────┬───────────────────────────────────────────┘ 22 + 23 + ┌─────────┼─────────┐ 24 + │ │ │ 25 + ┌────▼──┐ ┌──▼────┐ ┌──▼─────────┐ 26 + │Redis │ │ Jet- │ │ Moderation │ 27 + │Queue │ │stream │ │ Agent │ 28 + └────┬──┘ └───┬───┘ └──┬─────────┘ 29 + │ │ │ 30 + └─────────┼────────┘ 31 + 32 + ┌─────────▼──────────────┐ 33 + │ Queue Worker │ 34 + │ (concurrency control) │ 35 + └─────────┬──────────────┘ 36 + 37 + ┌─────────▼──────────────────────────┐ 38 + │ Image Processor │ 39 + │ (phash + matching logic) │ 40 + ├─────────────────────────────────────┤ 41 + │ ▪ Phash Computation (Sharp) │ 42 + │ ▪ Hamming Distance Matching │ 43 + │ ▪ Redis Phash Cache │ 44 + │ ▪ Moderation Claims Deduplication │ 45 + └────────────────────────────────────┘ 46 + ``` 47 + 48 + --- 49 + 50 + ## 1. Entry Point & Main Event Loop 51 + 52 + ### File: `src/main.ts` 53 + 54 + **Responsibilities:** 55 + - Jetstream connection management 56 + - Cursor persistence (recovery from crashes) 57 + - Queue/worker initialization 58 + - Moderation action dispatch 59 + 60 + **Key Flow:** 61 + 62 + ```typescript 63 + // 1. Load cursor from disk (enables resuming from last processed event) 64 + cursor = fs.readFileSync("cursor.txt") || Math.floor(Date.now() * 1000) 65 + 66 + // 2. Connect to Jetstream 67 + jetstream = new Jetstream({ 68 + endpoint: "wss://jetstream1.us-east.fire.hose.cam/subscribe", 69 + cursor, 70 + wantedCollections: ["app.bsky.feed.post"] 71 + }) 72 + 73 + // 3. Register handler for new posts 74 + jetstream.onCreate("app.bsky.feed.post", async (event) => { 75 + // Extract image blobs (CIDs) from post 76 + const blobs = extractBlobsFromEvent(event) 77 + 78 + // Enqueue for processing 79 + const job = { postUri, postCid, postDid, blobs, timestamp, attempts: 0 } 80 + await queue.enqueue(job) 81 + }) 82 + 83 + // 4. Start processing 84 + await worker.start() 85 + jetstream.start() 86 + 87 + // 5. On match found, execute moderation actions 88 + worker.onMatchFound((postUri, postCid, postDid, match) => { 89 + if (match.matchedCheck.toLabel) 90 + await createPostLabel(...) 91 + if (match.matchedCheck.reportPost) 92 + await createPostReport(...) 93 + if (match.matchedCheck.labelAcct) 94 + await createAccountLabel(...) 95 + if (match.matchedCheck.reportAcct) 96 + await createAccountReport(...) 97 + }) 98 + ``` 99 + 100 + **Cursor Handling (CRITICAL):** 101 + - Cursor is microsecond timestamp (µs, not ms) from Jetstream 102 + - Saved every 10 seconds (configurable via `CURSOR_UPDATE_INTERVAL`) 103 + - On restart, reads from `cursor.txt` to resume from last processed event 104 + - **This prevents duplicate processing of same posts** 105 + 106 + **Graceful Shutdown:** 107 + - Saves cursor before exit 108 + - Waits for active jobs to complete 109 + - Closes all connections 110 + 111 + --- 112 + 113 + ## 2. Jetstream Connection & Event Processing 114 + 115 + ### Event Structure 116 + 117 + Posts with embedded images trigger: 118 + 119 + ```typescript 120 + CommitCreateEvent<"app.bsky.feed.post"> { 121 + did: string // Author DID 122 + commit: { 123 + cid: string // Post CID 124 + collection: "app.bsky.feed.post" 125 + rkey: string // Post key 126 + record: { 127 + embed: { 128 + images?: [{image: {ref: {$link: CID}, mimeType}}] 129 + media?: {images: [...]} 130 + } 131 + } 132 + } 133 + } 134 + ``` 135 + 136 + **Image Extraction Logic:** 137 + - Handles both `embed.images` and `embed.media.images` (both are valid) 138 + - Filters out SVG and non-image types 139 + - Extracts CID from nested `ref.$link` field 140 + 141 + --- 142 + 143 + ## 3. Perceptual Hash Algorithm (CRITICAL - Exact Implementation) 144 + 145 + ### File: `src/hasher/phash.ts` 146 + 147 + ```typescript 148 + async function computePerceptualHash(buffer: Buffer): Promise<string> { 149 + // Step 1: Decode image via Sharp 150 + const image = sharp(buffer) 151 + const metadata = await image.metadata() 152 + 153 + // Step 2: Resize to 8x8 grayscale 154 + // CRITICAL: fit: "fill" preserves aspect ratio, may add padding 155 + const resized = await image 156 + .resize(8, 8, { fit: "fill" }) 157 + .grayscale() 158 + .raw() 159 + .toBuffer() 160 + 161 + // Step 3: Extract pixel values (Uint8Array, 0-255 range) 162 + const pixels = new Uint8Array(resized) 163 + 164 + // Step 4: Compute average brightness 165 + const avg = pixels.reduce((sum, val) => sum + val, 0) / pixels.length 166 + 167 + // Step 5: Create 64-bit hash (8x8 = 64 pixels) 168 + let hash = "" 169 + for (let i = 0; i < pixels.length; i++) { 170 + hash += pixels[i] > avg ? "1" : "0" 171 + } 172 + 173 + // Step 6: Convert binary string to hex (16 character string) 174 + return BigInt(`0b${hash}`).toString(16).padStart(16, "0") 175 + } 176 + ``` 177 + 178 + **Output Format:** 179 + - **Exactly 16 hex characters** (64 bits for 8x8 image) 180 + - Lowercase 181 + - Zero-padded 182 + 183 + **Example:** `"a1b2c3d4e5f6a7b8"` 184 + 185 + **Important Details:** 186 + 187 + 1. **Sharp resize behavior:** 188 + - `fit: "fill"` = no cropping, may add padding to preserve aspect ratio 189 + - This is intentional - handles images of any aspect ratio 190 + - Padding typically adds uniform brightness which affects hash slightly 191 + 192 + 2. **Grayscale conversion:** 193 + - Sharp converts RGB to single channel (standard luminosity formula) 194 + - Range: 0-255 195 + 196 + 3. **No normalization:** 197 + - Raw pixel values compared to mean (not normalized) 198 + - This is correct for perceptual hashing 199 + 200 + 4. **Deterministic:** 201 + - Same image buffer always produces same hash 202 + - Different images (even slight variations) can produce different hashes 203 + - BUT: Can match within Hamming distance threshold 204 + 205 + --- 206 + 207 + ## 4. Hamming Distance Matching 208 + 209 + ### File: `src/matcher/hamming.ts` 210 + 211 + ```typescript 212 + function hammingDistance(hash1: string, hash2: string): number { 213 + // Convert hex to BigInt 214 + const a = BigInt(`0x${hash1}`) 215 + const b = BigInt(`0x${hash2}`) 216 + 217 + // XOR finds differing bits 218 + const xor = a ^ b 219 + 220 + // Count set bits (Brian Kernighan's algorithm) 221 + let count = 0 222 + let n = xor 223 + while (n > 0n) { 224 + count++ 225 + n &= n - 1n // Remove rightmost set bit 226 + } 227 + 228 + return count 229 + } 230 + 231 + function findMatch(phash: string, checks: BlobCheck[]): BlobCheck | null { 232 + for (const check of checks) { 233 + const threshold = check.hammingThreshold ?? 5 234 + 235 + for (const checkPhash of check.phashes) { 236 + const distance = hammingDistance(phash, checkPhash) 237 + 238 + if (distance <= threshold) { 239 + return check // First match wins 240 + } 241 + } 242 + } 243 + 244 + return null 245 + } 246 + ``` 247 + 248 + **Key Semantics:** 249 + - Hamming distance = number of differing bits out of 64 250 + - Range: 0-64 251 + - Threshold comparison: `distance <= threshold` (inclusive) 252 + 253 + **Threshold Guidelines (from README):** 254 + - `0` = Exact match only (very strict) 255 + - `1-2` = Nearly identical (minor compression artifacts) 256 + - `3-4` = Very similar (slight edits, crops) 257 + - `5-8` = Similar (moderate edits) 258 + - `10+` = Loosely similar (too permissive) 259 + 260 + **Default threshold:** 5 (configurable per check or globally) 261 + 262 + --- 263 + 264 + ## 5. Queue & Worker Implementation 265 + 266 + ### File: `src/queue/redis-queue.ts` 267 + 268 + **Redis Keys:** 269 + ``` 270 + phash:queue:pending → List (FIFO for new jobs) 271 + phash:queue:processing → List (jobs being worked on) 272 + phash:queue:failed → List (jobs that exceeded retries) 273 + ``` 274 + 275 + **State Transitions:** 276 + 277 + ``` 278 + Pending → Pop (LPOP) → Processing (RPUSH) → Complete (LREM) 279 + └─→ Retry (RPUSH to Pending) 280 + └─→ Failed (RPUSH to Failed) 281 + ``` 282 + 283 + **Job Structure:** 284 + 285 + ```typescript 286 + interface ImageJob { 287 + postUri: string // "at://did/app.bsky.feed.post/rkey" 288 + postCid: string // CID of the post 289 + postDid: string // Author DID 290 + blobs: BlobReference[] // [{cid, mimeType?}] 291 + timestamp: number // When job was created 292 + attempts: number // Retry counter 293 + } 294 + ``` 295 + 296 + ### File: `src/queue/worker.ts` 297 + 298 + **Worker Configuration:** 299 + ```typescript 300 + interface WorkerConfig { 301 + concurrency: number // Number of parallel workers (default: 10) 302 + retryAttempts: number // Max retries (default: 3) 303 + retryDelay: number // MS between retries (default: 1000) 304 + pollInterval?: number // MS to wait if queue empty (default: 1000) 305 + } 306 + ``` 307 + 308 + **Processing Loop (per worker thread):** 309 + 310 + ``` 311 + 1. Dequeue job 312 + 2. For each blob in job: 313 + a. Check cache 314 + b. If miss, fetch blob from PDS 315 + c. Compute phash 316 + d. Store in cache 317 + e. Match against checks 318 + f. If match, emit onMatchFound event 319 + 3. Mark job complete 320 + 4. If error and retries < max: 321 + - Re-enqueue with incremented attempts 322 + 5. If error and retries exhausted: 323 + - Move to failed queue 324 + ``` 325 + 326 + **Error Handling:** 327 + - Corrupt/invalid images → logged at debug level (expected) 328 + - Network errors → retry (with backoff) 329 + - Moderation action failures → logged but don't block further processing 330 + 331 + **Concurrency Strategy:** 332 + - N workers running in parallel (default 10) 333 + - Each worker independently polls queue 334 + - Active job count tracked 335 + - Graceful shutdown waits for all jobs 336 + 337 + --- 338 + 339 + ## 6. Image Processing Pipeline 340 + 341 + ### File: `src/processor/image-processor.ts` 342 + 343 + **High-Level Flow:** 344 + 345 + ``` 346 + 1. DID/Check filtering (ignoreDID) 347 + 2. Cache lookup → if hit, use cached phash 348 + 3. Cache miss: 349 + a. Resolve PDS endpoint from DID document (PLCy) 350 + b. Check if repo has been taken down 351 + c. Fetch blob from PDS 352 + d. Compute phash 353 + e. Cache result 354 + 4. Match against checks 355 + 5. Return MatchResult (if match found) 356 + ``` 357 + 358 + **Key Implementation Details:** 359 + 360 + **DID → PDS Resolution:** 361 + ```typescript 362 + resolvePds(did: string) { 363 + // GET https://plc.directory/{did} 364 + // Extract service with id="atproto_pds" and type="AtprotoPersonalDataServer" 365 + // Return serviceEndpoint URL 366 + 367 + // Cached per DID to avoid repeated lookups 368 + } 369 + ``` 370 + 371 + **Blob Fetch:** 372 + ```typescript 373 + // GET {pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid} 374 + // Returns raw blob data as Buffer 375 + // Includes redirect: 'follow' to handle PDS redirects 376 + ``` 377 + 378 + **Taken-Down Repo Check:** 379 + ```typescript 380 + checkRepoTakendown(did: string, pdsEndpoint: string) { 381 + // GET {pdsEndpoint}/xrpc/com.atproto.repo.describeRepo?repo={did} 382 + // If error === "RepoTakendown", skip processing 383 + // Cached per DID to avoid repeated checks 384 + } 385 + ``` 386 + 387 + **MatchResult Creation:** 388 + ```typescript 389 + createMatchResult(phash: string, check: BlobCheck): MatchResult | null { 390 + for (const checkPhash of check.phashes) { 391 + const distance = hammingDistance(phash, checkPhash) 392 + const threshold = check.hammingThreshold ?? defaultThreshold 393 + 394 + if (distance <= threshold) { 395 + return { 396 + phash, 397 + matchedCheck: check, 398 + matchedPhash: checkPhash, 399 + hammingDistance: distance 400 + } 401 + } 402 + } 403 + return null 404 + } 405 + ``` 406 + 407 + **Caches (in-memory, in ImageProcessor):** 408 + ```typescript 409 + pdsCache: Map<did, pdsEndpoint|null> // PDS resolution 410 + repoTakendownCache: Map<did, boolean> // Taken-down status 411 + ``` 412 + 413 + **These are NOT persisted** - they're per-process memory optimization. 414 + 415 + --- 416 + 417 + ## 7. Redis Cache Structure 418 + 419 + ### File: `src/cache/phash-cache.ts` 420 + 421 + **Purpose:** Cache computed phashes to avoid re-fetching viral images 422 + 423 + **Key Pattern:** `phash:cache:{cid}` → hex string (phash) 424 + 425 + **TTL:** Configurable (default: 86400 seconds = 24 hours) 426 + 427 + **Operations:** 428 + - `get(cid)` → returns cached phash or null 429 + - `set(cid, phash)` → stores with TTL 430 + - `delete(cid)` → immediate removal 431 + - `clear()` → remove all cache entries 432 + - `getStats()` → returns cache size 433 + 434 + **Cache Hit Rate:** 435 + - Metrics track cache hits vs misses 436 + - Typical: 20-40% hit rate (viral images) 437 + 438 + --- 439 + 440 + ## 8. Moderation Claims (Deduplication) 441 + 442 + ### File: `src/cache/moderation-claims.ts` 443 + 444 + **Purpose:** Prevent duplicate moderation actions (labels/reports) within 7 days 445 + 446 + **Key Patterns:** 447 + ``` 448 + claim:post:label:{uri}:{label} → "1" (TTL: 7 days) 449 + claim:account:label:{did}:{label} → "1" (TTL: 7 days) 450 + claim:account:comment:{did}:{uri} → "1" (TTL: 7 days) 451 + ``` 452 + 453 + **TTL:** 604800 seconds = 7 days (hardcoded, not configurable) 454 + 455 + **Operations (all use SET ... NX):** 456 + - `tryClaimPostLabel(uri, label)` → tries to acquire claim, returns boolean 457 + - `tryClaimAccountLabel(did, label)` → tries to acquire claim, returns boolean 458 + - `tryClaimAccountComment(did, uri)` → tries to acquire claim, returns boolean 459 + 460 + **Flow in Moderation Actions:** 461 + 462 + ```typescript 463 + // Before creating post label 464 + const claimed = await claims.tryClaimPostLabel(uri, label) 465 + if (!claimed) { 466 + // Already labeled in past 7 days 467 + metrics?.increment("labelsCached") 468 + return 469 + } 470 + 471 + // Then check if label already exists in Ozone (race condition safety) 472 + const hasLabel = await checkRecordLabels(uri, label) 473 + if (hasLabel) { 474 + metrics?.increment("labelsCached") 475 + return 476 + } 477 + 478 + // Otherwise, create label via Ozone API 479 + ``` 480 + 481 + **Why both checks?** 482 + 1. Redis claim = "we acted" (fast, distributed) 483 + 2. Ozone check = "label still exists" (authoritative) 484 + - This handles race conditions if multiple services label simultaneously 485 + 486 + --- 487 + 488 + ## 9. Moderation Action Flows 489 + 490 + ### File: `src/moderation/post.ts` 491 + 492 + **createPostLabel:** 493 + ```typescript 494 + // 1. Try claim → if fail, exit (already labeled recently) 495 + // 2. Check if label exists → if yes, exit 496 + // 3. Emit event via Ozone API: 497 + // - $type: "tools.ozone.moderation.defs#modEventLabel" 498 + // - subject: {uri, cid} (strongRef) 499 + // - createLabelVals: [label] 500 + // - comment: "{timestamp}: {comment} at {uri} with phash \"{phash}\"" 501 + // 4. Rate-limited via limit() function 502 + ``` 503 + 504 + **createPostReport:** 505 + ```typescript 506 + // Same flow but: 507 + // - $type: "tools.ozone.moderation.defs#modEventReport" 508 + // - reportType: "com.atproto.moderation.defs#reasonOther" 509 + // - No claim check needed (reports can repeat) 510 + ``` 511 + 512 + ### File: `src/moderation/account.ts` 513 + 514 + **createAccountLabel:** 515 + ```typescript 516 + // Similar to post label but: 517 + // - subject: {$type: "com.atproto.admin.defs#repoRef", did} 518 + // - Claims work per (did, label) pair 519 + ``` 520 + 521 + **createAccountReport:** 522 + ```typescript 523 + // Similar to post report but: 524 + // - subject: {$type: "com.atproto.admin.defs#repoRef", did} 525 + ``` 526 + 527 + **createAccountComment:** 528 + ```typescript 529 + // Comments on account (metadata only, no label) 530 + // Claims prevent duplicate comments for same (did, uri) pair 531 + ``` 532 + 533 + **API Headers (All Moderation Calls):** 534 + ```typescript 535 + { 536 + encoding: "application/json", 537 + headers: { 538 + "atproto-proxy": `${modDid}#atproto_labeler`, 539 + "atproto-accept-labelers": "did:plc:ar7c4by46qjdydhdevvrndac;redact" 540 + } 541 + } 542 + ``` 543 + 544 + **These headers:** 545 + - Tell Ozone to proxy request through moderation account 546 + - Request redaction of sensitive PII 547 + - Are critical for proper audit trails 548 + 549 + --- 550 + 551 + ## 10. Rate Limiting 552 + 553 + ### File: `src/limits.ts` 554 + 555 + **Two-Level Strategy:** 556 + 557 + **Level 1: Concurrency Control** 558 + - p-ratelimit library 559 + - Limits to 24 concurrent requests 560 + - Prevents overwhelming Ozone API with simultaneous requests 561 + 562 + **Level 2: Header-Based Rate Limit Tracking** 563 + - Ozone API returns headers: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` 564 + - Service maintains state and waits if critically low 565 + - Safety buffer: 5 requests reserved 566 + - Only waits if `remaining <= 5` 567 + 568 + **Dynamic Rate Limit State:** 569 + ```typescript 570 + interface RateLimitState { 571 + limit: number // e.g., 280 requests per window 572 + remaining: number // Current budget 573 + reset: number // Unix timestamp when resets 574 + } 575 + ``` 576 + 577 + **How moderation actions use limits:** 578 + ```typescript 579 + await limit(async () => { 580 + // This will: 581 + // 1. Check concurrency (max 24 concurrent) 582 + // 2. Check remaining budget (wait if <5 remaining) 583 + // 3. Execute request 584 + // 4. Parse RateLimit headers and update state 585 + return await agent.tools.ozone.moderation.emitEvent(...) 586 + }) 587 + ``` 588 + 589 + **Prometheus Metrics:** 590 + - `rate_limit_waits_total` → how many times we waited 591 + - `rate_limit_wait_duration_seconds` → how long waits took 592 + - `concurrent_requests` → live count of concurrent calls 593 + 594 + --- 595 + 596 + ## 11. Agent & Authentication 597 + 598 + ### File: `src/agent.ts` 599 + 600 + **Session Management:** 601 + 602 + ```typescript 603 + // Session file: .session (chmod 600, NOT in git) 604 + // Contains: {accessJwt, refreshJwt, did, handle, email, ...} 605 + 606 + // Login flow: 607 + 1. Try load session from .session file 608 + 2. If loaded, try resume session (verify with getProfile call) 609 + 3. If resume fails or no saved session, perform fresh login 610 + 4. Save session after successful login 611 + 612 + // Token refresh: 613 + - JWT lifetime: 2 hours 614 + - Refresh scheduled at 80% of lifetime (~96 minutes) 615 + - On refresh failure, fallback to fresh login with retries 616 + ``` 617 + 618 + **Retry Strategy:** 619 + - MAX_LOGIN_RETRIES = 3 620 + - RETRY_DELAY_MS = 2000 621 + - If all retries fail, process exits with error 622 + 623 + **Undici Configuration:** 624 + ```typescript 625 + const dispatcher = new Agent({ 626 + connect: { timeout: 20_000 }, 627 + keepAliveTimeout: 10_000, 628 + keepAliveMaxTimeout: 20_000 629 + }) 630 + setGlobalDispatcher(dispatcher) 631 + ``` 632 + 633 + This ensures robust HTTP handling for PDS/Ozone API calls. 634 + 635 + --- 636 + 637 + ## 12. Configuration Management 638 + 639 + ### File: `src/config/index.ts` 640 + 641 + **All settings sourced from environment variables:** 642 + 643 + ```typescript 644 + // REQUIRED 645 + LABELER_DID // e.g., "did:plc:..." 646 + LABELER_HANDLE // e.g., "labeler.bsky.social" 647 + LABELER_PASSWORD // app password (NOT user password) 648 + 649 + // Processing 650 + JETSTREAM_URL // (default: wss://jetstream.atproto.tools/subscribe) 651 + REDIS_URL // (default: redis://localhost:6379) 652 + PROCESSING_CONCURRENCY // (default: 10) 653 + RETRY_ATTEMPTS // (default: 3) 654 + RETRY_DELAY_MS // (default: 1000) 655 + CACHE_ENABLED // (default: true) 656 + CACHE_TTL_SECONDS // (default: 86400) 657 + 658 + // PDS/Ozone 659 + PDS_ENDPOINT // (default: https://bsky.social) 660 + OZONE_URL // (no default) 661 + OZONE_PDS // (no default - used for agent.service) 662 + MOD_DID // (no default - used in moderation headers) 663 + 664 + // Phash 665 + PHASH_HAMMING_THRESHOLD // (default: 3) 666 + 667 + // Logging 668 + LOG_LEVEL // (default: debug/info) 669 + NODE_ENV // (defaults to production) 670 + ``` 671 + 672 + --- 673 + 674 + ## 13. Metrics & Observability 675 + 676 + ### File: `src/metrics/collector.ts` 677 + 678 + **Simple in-memory metrics:** 679 + 680 + ```typescript 681 + class MetricsCollector { 682 + counters: Map<string, number> 683 + 684 + increment(metric: string, value?: number) 685 + get(metric: string): number 686 + getAll(): Record<string, number> 687 + getWithRates(): Record<string, number|string> 688 + } 689 + ``` 690 + 691 + **Key Metrics Tracked:** 692 + 693 + ``` 694 + postsProcessed // Jobs completed 695 + blobsFetched // Blobs downloaded 696 + blobsProcessed // Blobs hashed successfully 697 + blobsCorrupted // Invalid images skipped 698 + fetchErrors // Blob fetch failures 699 + cacheHits // Phash cache hits 700 + cacheMisses // Phash cache misses 701 + matchesFound // Images matching known phashes 702 + labelsApplied // Labels successfully created 703 + labelsCached // Labels skipped (already claimed) 704 + reportsCreated // Reports successfully created 705 + ``` 706 + 707 + **Derived Rates (calculated on-demand):** 708 + ``` 709 + postsPerSecond = postsProcessed / uptimeSeconds 710 + blobsPerSecond = blobsProcessed / uptimeSeconds 711 + cacheHitRate = cacheHits / (cacheHits + cacheMisses) * 100 712 + matchRate = matchesFound / blobsProcessed * 100 713 + ``` 714 + 715 + **Prometheus Integration:** 716 + - Rate limit metrics via prom-client 717 + - Not exposed as metrics server (would need HTTP server) 718 + 719 + **Stats Logged:** 720 + - Every 60 seconds to logger 721 + - Includes worker stats, cache stats, metrics with rates 722 + 723 + --- 724 + 725 + ## 14. Logging 726 + 727 + ### File: `src/logger/index.ts` 728 + 729 + **Pino-based structured logging:** 730 + 731 + ```typescript 732 + logger.debug(...) // Development details 733 + logger.info(...) // Normal operation milestones 734 + logger.warn(...) // Unexpected but recoverable 735 + logger.error(...) // Errors (usually don't stop service) 736 + ``` 737 + 738 + **Development Mode:** 739 + - Pretty-printed output 740 + - Colors, timestamps, field names 741 + - Ignores pid/hostname 742 + 743 + **Production Mode:** 744 + - JSON logs (newline-delimited) 745 + - Parse with jq or log aggregators 746 + - Structured for monitoring 747 + 748 + **Log Levels:** 749 + - `process` field added manually to track sub-components 750 + - Examples: `{process: "MAIN"}`, `{process: "MODERATION"}` 751 + 752 + --- 753 + 754 + ## 15. Rules Configuration 755 + 756 + ### File: `rules/blobs.ts` 757 + 758 + **BlobCheck structure:** 759 + 760 + ```typescript 761 + interface BlobCheck { 762 + phashes: string[] // List of known bad phashes 763 + label: string // Label to apply (e.g., "troll") 764 + comment: string // Description of harm 765 + reportAcct: boolean // Report the account? 766 + labelAcct: boolean // Label the account? 767 + reportPost: boolean // Report the post? 768 + toLabel: boolean // Label the post? 769 + hammingThreshold?: number // Per-check threshold (overrides default) 770 + description?: string // Internal documentation 771 + ignoreDID?: string[] // Exempt accounts 772 + } 773 + ``` 774 + 775 + **Multiple checks evaluated in order:** 776 + - First match wins (no combining of multiple checks) 777 + - Each check can independently configure moderation actions 778 + 779 + **Example:** 780 + 781 + ```typescript 782 + { 783 + phashes: ["e0e0e0e0e0fcfefe", "9b9e00008f8fffff"], 784 + label: "harassment-image", 785 + comment: "Known harassment image", 786 + toLabel: true, // Label post 787 + reportPost: false, // Don't report post 788 + labelAcct: true, // Label account 789 + reportAcct: false, // Don't report account 790 + hammingThreshold: 3, // Strict threshold 791 + ignoreDID: ["did:plc:trusted-account"] // Don't check this account 792 + } 793 + ``` 794 + 795 + --- 796 + 797 + ## 16. Critical Gotchas & Nuances for Rust Rewrite 798 + 799 + ### 1. **Phash Algorithm Details** 800 + - Output MUST be exactly 16 hex chars (64-bit) 801 + - Lowercase 802 + - Zero-padded 803 + - Grayscale conversion via luminosity formula (not simple average) 804 + - 8x8 resize with aspect-ratio preservation 805 + 806 + ### 2. **Hamming Distance** 807 + - Uses Brian Kernighan's bit-counting algorithm 808 + - Range 0-64 809 + - Threshold comparison is `distance <= threshold` (INCLUSIVE) 810 + 811 + ### 3. **Cursor Persistence** 812 + - Is microsecond timestamp (NOT millisecond) 813 + - Must persist every 10 seconds 814 + - Resume from cursor prevents duplicate processing 815 + - Write to `cursor.txt` in working directory 816 + 817 + ### 4. **Queue State Machine** 818 + - Never lose jobs mid-processing 819 + - Pending → Processing → Complete is atomic per job 820 + - On crash, jobs in Processing list are reprocessed on restart 821 + - Failed queue accumulates jobs beyond retry limit 822 + 823 + ### 5. **Blob Fetch Strategy** 824 + - Requires PDS resolution from PLCy first 825 + - Many PDSes redirect blob endpoints 826 + - Must follow redirects 827 + - Check repo.takedown status before fetching 828 + - Cache both PDS endpoint and takedown status (per process) 829 + 830 + ### 6. **Moderation Claims Dedup** 831 + - 7-day TTL (hardcoded in moderation-claims.ts) 832 + - Uses Redis SET ... NX (atomic) 833 + - Must still check Ozone API (race condition safety) 834 + - Account claims are per (did, label) pair 835 + 836 + ### 7. **Rate Limiting** 837 + - TWO separate mechanisms (concurrency + header-based) 838 + - Only waits when `remaining <= 5` (safety buffer) 839 + - Ozone returns RateLimit headers 840 + - Conservative defaults: 280 requests per 30-second window 841 + - Must not block job processing (only moderation action calls) 842 + 843 + ### 8. **Session Token Management** 844 + - Tokens live ~2 hours 845 + - Refresh at 80% of lifetime (~96 minutes) 846 + - Session saved to `.session` file (must be chmod 600) 847 + - Resume session before fresh login 848 + - Multiple login attempts = shared promise (singleton) 849 + 850 + ### 9. **Image Mime Type Filtering** 851 + - Skip SVG images 852 + - Only process image/* types 853 + - mimeType may be missing (optional field) 854 + 855 + ### 10. **Error Handling Patterns** 856 + - Corrupt images → debug log, skip (expected) 857 + - Network errors → retry with backoff 858 + - Moderation action failures → log but continue (don't block) 859 + - Takden-down repos → skip silently 860 + - Missing PDS → warn and skip blob 861 + 862 + ### 11. **Config Environment Variables** 863 + - No defaults for labeler credentials (must be provided) 864 + - Ozone URL/PDS used differently (URL is separate from service endpoint) 865 + - OZONE_PDS is the agent.service endpoint 866 + - OZONE_URL is for display/configuration only 867 + 868 + ### 12. **Redis Key Patterns** 869 + ``` 870 + phash:queue:pending → List 871 + phash:queue:processing → List 872 + phash:queue:failed → List 873 + phash:cache:{cid} → String 874 + claim:post:label:{uri}:{label} → String 875 + claim:account:label:{did}:{label} → String 876 + claim:account:comment:{did}:{uri} → String 877 + ``` 878 + 879 + ### 13. **Jetstream Event Structure** 880 + - Only processes `app.bsky.feed.post` creates 881 + - Ignores updates/deletes 882 + - Image extraction handles nested embed structures 883 + - Blob CID in `ref.$link` field 884 + 885 + ### 14. **Metrics Naming Conventions** 886 + - Snake_case for Prometheus metrics 887 + - Counter suffixes: `_total` 888 + - Gauge suffixes: none (just name) 889 + - Histogram suffixes: `_seconds` (for duration) 890 + 891 + ### 15. **Graceful Shutdown** 892 + - Saves cursor to disk 893 + - Waits for active jobs (polling every 100ms) 894 + - Closes all connections 895 + - SIGINT/SIGTERM trigger shutdown 896 + 897 + --- 898 + 899 + ## Architecture Strengths & Assumptions 900 + 901 + **What Works Well:** 902 + 1. ✅ Decoupled Jetstream ingestion from processing (queue isolates) 903 + 2. ✅ Per-job retry strategy with exponential backoff 904 + 3. ✅ Cache hit rate for viral images reduces PDS load 905 + 4. ✅ Redis claims prevent duplicate moderation actions 906 + 5. ✅ Cursor persistence enables recovery 907 + 6. ✅ Rate limiting respects Ozone API limits 908 + 909 + **Assumptions/Constraints:** 910 + 1. Redis is always available (no in-memory fallback) 911 + 2. PDS/Ozone/PLCy endpoints are reachable 912 + 3. Jetstream can resume from cursor 913 + 4. Single-process (no distributed workers) 914 + 5. In-memory caches (PDS/takdown) lost on restart 915 + 916 + --- 917 + 918 + ## Typical Performance Characteristics 919 + 920 + **VM Requirements (from README):** 921 + - **Minimal:** 2GB RAM, 2 vCPU, 10GB disk 922 + - **Recommended:** 4GB RAM, 2-4 vCPU, 20GB disk 923 + 924 + **Throughput:** 925 + - Concurrency default: 10 workers 926 + - Each worker can process ~1-5 images/second (depends on network) 927 + - Total: 10-50 images/second throughput 928 + - Cache hit rate: 20-40% (viral images) 929 + 930 + **Latency:** 931 + - Jetstream event → job enqueue: <100ms 932 + - Job dequeue → phash computed: 200-500ms (depends on network/image size) 933 + - Phash → moderation action: 100-200ms (rate-limited) 934 + 935 + --- 936 + 937 + ## Testing Coverage 938 + 939 + **Unit Tests Exist For:** 940 + - ✅ Phash algorithm (format, determinism, slight variations) 941 + - ✅ Hamming distance (exact match, thresholds, edge cases) 942 + - ✅ Cache hit/miss 943 + - ✅ Queue operations 944 + - ✅ Image processor logic 945 + 946 + **Integration Tests:** 947 + - ✅ Queue persistence across restart 948 + - ✅ End-to-end job processing 949 + 950 + **No Tests For:** 951 + - Jetstream connection handling 952 + - Moderation action API calls 953 + - Session management 954 + - Rate limiting 955 + 956 + --- 957 + 958 + ## File Structure Summary 959 + 960 + ``` 961 + src/ 962 + ├── main.ts # Entry point, Jetstream orchestrator 963 + ├── types.ts # Core interfaces (BlobCheck, ImageJob, MatchResult) 964 + ├── agent.ts # ATProto agent, session management 965 + ├── session.ts # Session file persistence 966 + ├── limits.ts # Rate limiting (concurrency + header-based) 967 + ├── config/ 968 + │ └── index.ts # Environment variable parsing 969 + ├── logger/ 970 + │ └── index.ts # Pino structured logging 971 + ├── hasher/ 972 + │ └── phash.ts # Perceptual hash computation 973 + ├── matcher/ 974 + │ └── hamming.ts # Hamming distance, match finding 975 + ├── cache/ 976 + │ ├── phash-cache.ts # Redis phash cache 977 + │ └── moderation-claims.ts # Redis deduplication claims 978 + ├── queue/ 979 + │ ├── redis-queue.ts # Redis job queue 980 + │ └── worker.ts # Worker pool, job processing loop 981 + ├── processor/ 982 + │ └── image-processor.ts # Blob fetch, phash compute, matching 983 + ├── moderation/ 984 + │ ├── post.ts # Post label/report actions 985 + │ └── account.ts # Account label/report/comment actions 986 + └── metrics/ 987 + └── collector.ts # In-memory metrics 988 + 989 + rules/ 990 + └── blobs.ts # BlobCheck definitions 991 + 992 + tests/ 993 + ├── unit/ 994 + │ ├── phash.test.ts 995 + │ ├── hamming.test.ts 996 + │ ├── phash-cache.test.ts 997 + │ └── image-processor.test.ts 998 + └── integration/ 999 + └── queue.test.ts 1000 + ``` 1001 + 1002 + --- 1003 + 1004 + ## Entry Point Summary 1005 + 1006 + The entire system starts with: 1007 + 1008 + 1. **main.ts** loads config, connects Redis, logs in to Ozone 1009 + 2. **Jetstream** connects and starts emitting post events 1010 + 3. **Queue** receives jobs (post + blob CIDs) 1011 + 4. **Worker pool** dequeues jobs and processes them in parallel 1012 + 5. **ImageProcessor** fetches blobs, computes phashes, finds matches 1013 + 6. **Moderation** actions (label/report) triggered on matches 1014 + 7. **Metrics/Logging** tracked throughout 1015 + 8. **Graceful shutdown** saves cursor and exits cleanly 1016 + 1017 + This is a **real-time, event-driven system** - no polling, no batch processing. Every post on Bluesky is potentially seen and processed within seconds. 1018 +
+5737
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "abnf" 7 + version = "0.13.0" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" 10 + dependencies = [ 11 + "abnf-core", 12 + "nom 7.1.3", 13 + ] 14 + 15 + [[package]] 16 + name = "abnf-core" 17 + version = "0.5.0" 18 + source = "registry+https://github.com/rust-lang/crates.io-index" 19 + checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" 20 + dependencies = [ 21 + "nom 7.1.3", 22 + ] 23 + 24 + [[package]] 25 + name = "addr2line" 26 + version = "0.25.1" 27 + source = "registry+https://github.com/rust-lang/crates.io-index" 28 + checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" 29 + dependencies = [ 30 + "gimli", 31 + ] 32 + 33 + [[package]] 34 + name = "adler2" 35 + version = "2.0.1" 36 + source = "registry+https://github.com/rust-lang/crates.io-index" 37 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 38 + 39 + [[package]] 40 + name = "adler32" 41 + version = "1.2.0" 42 + source = "registry+https://github.com/rust-lang/crates.io-index" 43 + checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" 44 + 45 + [[package]] 46 + name = "aho-corasick" 47 + version = "1.1.3" 48 + source = "registry+https://github.com/rust-lang/crates.io-index" 49 + checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 50 + dependencies = [ 51 + "memchr", 52 + ] 53 + 54 + [[package]] 55 + name = "aliasable" 56 + version = "0.1.3" 57 + source = "registry+https://github.com/rust-lang/crates.io-index" 58 + checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 59 + 60 + [[package]] 61 + name = "aligned-vec" 62 + version = "0.6.4" 63 + source = "registry+https://github.com/rust-lang/crates.io-index" 64 + checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" 65 + dependencies = [ 66 + "equator", 67 + ] 68 + 69 + [[package]] 70 + name = "alloc-no-stdlib" 71 + version = "2.0.4" 72 + source = "registry+https://github.com/rust-lang/crates.io-index" 73 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 74 + 75 + [[package]] 76 + name = "alloc-stdlib" 77 + version = "0.2.2" 78 + source = "registry+https://github.com/rust-lang/crates.io-index" 79 + checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 80 + dependencies = [ 81 + "alloc-no-stdlib", 82 + ] 83 + 84 + [[package]] 85 + name = "android_system_properties" 86 + version = "0.1.5" 87 + source = "registry+https://github.com/rust-lang/crates.io-index" 88 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 89 + dependencies = [ 90 + "libc", 91 + ] 92 + 93 + [[package]] 94 + name = "anyhow" 95 + version = "1.0.100" 96 + source = "registry+https://github.com/rust-lang/crates.io-index" 97 + checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 98 + 99 + [[package]] 100 + name = "arbitrary" 101 + version = "1.4.2" 102 + source = "registry+https://github.com/rust-lang/crates.io-index" 103 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 104 + 105 + [[package]] 106 + name = "arc-swap" 107 + version = "1.7.1" 108 + source = "registry+https://github.com/rust-lang/crates.io-index" 109 + checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" 110 + 111 + [[package]] 112 + name = "arg_enum_proc_macro" 113 + version = "0.3.4" 114 + source = "registry+https://github.com/rust-lang/crates.io-index" 115 + checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" 116 + dependencies = [ 117 + "proc-macro2", 118 + "quote", 119 + "syn 2.0.108", 120 + ] 121 + 122 + [[package]] 123 + name = "arrayvec" 124 + version = "0.7.6" 125 + source = "registry+https://github.com/rust-lang/crates.io-index" 126 + checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 127 + 128 + [[package]] 129 + name = "ascii" 130 + version = "1.1.0" 131 + source = "registry+https://github.com/rust-lang/crates.io-index" 132 + checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" 133 + 134 + [[package]] 135 + name = "assert-json-diff" 136 + version = "2.0.2" 137 + source = "registry+https://github.com/rust-lang/crates.io-index" 138 + checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" 139 + dependencies = [ 140 + "serde", 141 + "serde_json", 142 + ] 143 + 144 + [[package]] 145 + name = "async-compression" 146 + version = "0.4.32" 147 + source = "registry+https://github.com/rust-lang/crates.io-index" 148 + checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" 149 + dependencies = [ 150 + "compression-codecs", 151 + "compression-core", 152 + "futures-core", 153 + "pin-project-lite", 154 + "tokio", 155 + ] 156 + 157 + [[package]] 158 + name = "async-stream" 159 + version = "0.3.6" 160 + source = "registry+https://github.com/rust-lang/crates.io-index" 161 + checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" 162 + dependencies = [ 163 + "async-stream-impl", 164 + "futures-core", 165 + "pin-project-lite", 166 + ] 167 + 168 + [[package]] 169 + name = "async-stream-impl" 170 + version = "0.3.6" 171 + source = "registry+https://github.com/rust-lang/crates.io-index" 172 + checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" 173 + dependencies = [ 174 + "proc-macro2", 175 + "quote", 176 + "syn 2.0.108", 177 + ] 178 + 179 + [[package]] 180 + name = "async-trait" 181 + version = "0.1.89" 182 + source = "registry+https://github.com/rust-lang/crates.io-index" 183 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 184 + dependencies = [ 185 + "proc-macro2", 186 + "quote", 187 + "syn 2.0.108", 188 + ] 189 + 190 + [[package]] 191 + name = "atomic-waker" 192 + version = "1.1.2" 193 + source = "registry+https://github.com/rust-lang/crates.io-index" 194 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 195 + 196 + [[package]] 197 + name = "autocfg" 198 + version = "1.5.0" 199 + source = "registry+https://github.com/rust-lang/crates.io-index" 200 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 201 + 202 + [[package]] 203 + name = "av1-grain" 204 + version = "0.2.5" 205 + source = "registry+https://github.com/rust-lang/crates.io-index" 206 + checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" 207 + dependencies = [ 208 + "anyhow", 209 + "arrayvec", 210 + "log", 211 + "nom 8.0.0", 212 + "num-rational", 213 + "v_frame", 214 + ] 215 + 216 + [[package]] 217 + name = "avif-serialize" 218 + version = "0.8.6" 219 + source = "registry+https://github.com/rust-lang/crates.io-index" 220 + checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" 221 + dependencies = [ 222 + "arrayvec", 223 + ] 224 + 225 + [[package]] 226 + name = "backon" 227 + version = "1.6.0" 228 + source = "registry+https://github.com/rust-lang/crates.io-index" 229 + checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" 230 + dependencies = [ 231 + "fastrand", 232 + ] 233 + 234 + [[package]] 235 + name = "backtrace" 236 + version = "0.3.76" 237 + source = "registry+https://github.com/rust-lang/crates.io-index" 238 + checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" 239 + dependencies = [ 240 + "addr2line", 241 + "cfg-if", 242 + "libc", 243 + "miniz_oxide", 244 + "object", 245 + "rustc-demangle", 246 + "windows-link 0.2.1", 247 + ] 248 + 249 + [[package]] 250 + name = "backtrace-ext" 251 + version = "0.2.1" 252 + source = "registry+https://github.com/rust-lang/crates.io-index" 253 + checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" 254 + dependencies = [ 255 + "backtrace", 256 + ] 257 + 258 + [[package]] 259 + name = "base-x" 260 + version = "0.2.11" 261 + source = "registry+https://github.com/rust-lang/crates.io-index" 262 + checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 263 + 264 + [[package]] 265 + name = "base16ct" 266 + version = "0.2.0" 267 + source = "registry+https://github.com/rust-lang/crates.io-index" 268 + checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 269 + 270 + [[package]] 271 + name = "base256emoji" 272 + version = "1.0.2" 273 + source = "registry+https://github.com/rust-lang/crates.io-index" 274 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 275 + dependencies = [ 276 + "const-str", 277 + "match-lookup", 278 + ] 279 + 280 + [[package]] 281 + name = "base64" 282 + version = "0.13.1" 283 + source = "registry+https://github.com/rust-lang/crates.io-index" 284 + checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 285 + 286 + [[package]] 287 + name = "base64" 288 + version = "0.22.1" 289 + source = "registry+https://github.com/rust-lang/crates.io-index" 290 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 291 + 292 + [[package]] 293 + name = "base64ct" 294 + version = "1.8.0" 295 + source = "registry+https://github.com/rust-lang/crates.io-index" 296 + checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 297 + 298 + [[package]] 299 + name = "bit_field" 300 + version = "0.10.3" 301 + source = "registry+https://github.com/rust-lang/crates.io-index" 302 + checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" 303 + 304 + [[package]] 305 + name = "bitflags" 306 + version = "2.10.0" 307 + source = "registry+https://github.com/rust-lang/crates.io-index" 308 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 309 + 310 + [[package]] 311 + name = "bitstream-io" 312 + version = "2.6.0" 313 + source = "registry+https://github.com/rust-lang/crates.io-index" 314 + checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" 315 + 316 + [[package]] 317 + name = "block-buffer" 318 + version = "0.10.4" 319 + source = "registry+https://github.com/rust-lang/crates.io-index" 320 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 321 + dependencies = [ 322 + "generic-array", 323 + ] 324 + 325 + [[package]] 326 + name = "bon" 327 + version = "3.8.1" 328 + source = "registry+https://github.com/rust-lang/crates.io-index" 329 + checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" 330 + dependencies = [ 331 + "bon-macros", 332 + "rustversion", 333 + ] 334 + 335 + [[package]] 336 + name = "bon-macros" 337 + version = "3.8.1" 338 + source = "registry+https://github.com/rust-lang/crates.io-index" 339 + checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" 340 + dependencies = [ 341 + "darling", 342 + "ident_case", 343 + "prettyplease", 344 + "proc-macro2", 345 + "quote", 346 + "rustversion", 347 + "syn 2.0.108", 348 + ] 349 + 350 + [[package]] 351 + name = "borsh" 352 + version = "1.5.7" 353 + source = "registry+https://github.com/rust-lang/crates.io-index" 354 + checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" 355 + dependencies = [ 356 + "cfg_aliases", 357 + ] 358 + 359 + [[package]] 360 + name = "brotli" 361 + version = "3.5.0" 362 + source = "registry+https://github.com/rust-lang/crates.io-index" 363 + checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" 364 + dependencies = [ 365 + "alloc-no-stdlib", 366 + "alloc-stdlib", 367 + "brotli-decompressor", 368 + ] 369 + 370 + [[package]] 371 + name = "brotli-decompressor" 372 + version = "2.5.1" 373 + source = "registry+https://github.com/rust-lang/crates.io-index" 374 + checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" 375 + dependencies = [ 376 + "alloc-no-stdlib", 377 + "alloc-stdlib", 378 + ] 379 + 380 + [[package]] 381 + name = "btree-range-map" 382 + version = "0.7.2" 383 + source = "registry+https://github.com/rust-lang/crates.io-index" 384 + checksum = "1be5c9672446d3800bcbcaabaeba121fe22f1fb25700c4562b22faf76d377c33" 385 + dependencies = [ 386 + "btree-slab", 387 + "cc-traits", 388 + "range-traits", 389 + "serde", 390 + "slab", 391 + ] 392 + 393 + [[package]] 394 + name = "btree-slab" 395 + version = "0.6.1" 396 + source = "registry+https://github.com/rust-lang/crates.io-index" 397 + checksum = "7a2b56d3029f075c4fa892428a098425b86cef5c89ae54073137ece416aef13c" 398 + dependencies = [ 399 + "cc-traits", 400 + "slab", 401 + "smallvec", 402 + ] 403 + 404 + [[package]] 405 + name = "buf_redux" 406 + version = "0.8.4" 407 + source = "registry+https://github.com/rust-lang/crates.io-index" 408 + checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" 409 + dependencies = [ 410 + "memchr", 411 + "safemem", 412 + ] 413 + 414 + [[package]] 415 + name = "built" 416 + version = "0.7.7" 417 + source = "registry+https://github.com/rust-lang/crates.io-index" 418 + checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" 419 + 420 + [[package]] 421 + name = "bumpalo" 422 + version = "3.19.0" 423 + source = "registry+https://github.com/rust-lang/crates.io-index" 424 + checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 425 + 426 + [[package]] 427 + name = "bytemuck" 428 + version = "1.24.0" 429 + source = "registry+https://github.com/rust-lang/crates.io-index" 430 + checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" 431 + 432 + [[package]] 433 + name = "byteorder" 434 + version = "1.5.0" 435 + source = "registry+https://github.com/rust-lang/crates.io-index" 436 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 437 + 438 + [[package]] 439 + name = "byteorder-lite" 440 + version = "0.1.0" 441 + source = "registry+https://github.com/rust-lang/crates.io-index" 442 + checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" 443 + 444 + [[package]] 445 + name = "bytes" 446 + version = "1.10.1" 447 + source = "registry+https://github.com/rust-lang/crates.io-index" 448 + checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 449 + dependencies = [ 450 + "serde", 451 + ] 452 + 453 + [[package]] 454 + name = "cbor4ii" 455 + version = "0.2.14" 456 + source = "registry+https://github.com/rust-lang/crates.io-index" 457 + checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4" 458 + dependencies = [ 459 + "serde", 460 + ] 461 + 462 + [[package]] 463 + name = "cc" 464 + version = "1.2.41" 465 + source = "registry+https://github.com/rust-lang/crates.io-index" 466 + checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" 467 + dependencies = [ 468 + "find-msvc-tools", 469 + "jobserver", 470 + "libc", 471 + "shlex", 472 + ] 473 + 474 + [[package]] 475 + name = "cc-traits" 476 + version = "2.0.0" 477 + source = "registry+https://github.com/rust-lang/crates.io-index" 478 + checksum = "060303ef31ef4a522737e1b1ab68c67916f2a787bb2f4f54f383279adba962b5" 479 + dependencies = [ 480 + "slab", 481 + ] 482 + 483 + [[package]] 484 + name = "cesu8" 485 + version = "1.1.0" 486 + source = "registry+https://github.com/rust-lang/crates.io-index" 487 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 488 + 489 + [[package]] 490 + name = "cfg-expr" 491 + version = "0.15.8" 492 + source = "registry+https://github.com/rust-lang/crates.io-index" 493 + checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" 494 + dependencies = [ 495 + "smallvec", 496 + "target-lexicon", 497 + ] 498 + 499 + [[package]] 500 + name = "cfg-if" 501 + version = "1.0.4" 502 + source = "registry+https://github.com/rust-lang/crates.io-index" 503 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 504 + 505 + [[package]] 506 + name = "cfg_aliases" 507 + version = "0.2.1" 508 + source = "registry+https://github.com/rust-lang/crates.io-index" 509 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 510 + 511 + [[package]] 512 + name = "chrono" 513 + version = "0.4.42" 514 + source = "registry+https://github.com/rust-lang/crates.io-index" 515 + checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 516 + dependencies = [ 517 + "iana-time-zone", 518 + "js-sys", 519 + "num-traits", 520 + "wasm-bindgen", 521 + "windows-link 0.2.1", 522 + ] 523 + 524 + [[package]] 525 + name = "chunked_transfer" 526 + version = "1.5.0" 527 + source = "registry+https://github.com/rust-lang/crates.io-index" 528 + checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" 529 + 530 + [[package]] 531 + name = "ciborium" 532 + version = "0.2.2" 533 + source = "registry+https://github.com/rust-lang/crates.io-index" 534 + checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" 535 + dependencies = [ 536 + "ciborium-io", 537 + "ciborium-ll", 538 + "serde", 539 + ] 540 + 541 + [[package]] 542 + name = "ciborium-io" 543 + version = "0.2.2" 544 + source = "registry+https://github.com/rust-lang/crates.io-index" 545 + checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" 546 + 547 + [[package]] 548 + name = "ciborium-ll" 549 + version = "0.2.2" 550 + source = "registry+https://github.com/rust-lang/crates.io-index" 551 + checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" 552 + dependencies = [ 553 + "ciborium-io", 554 + "half", 555 + ] 556 + 557 + [[package]] 558 + name = "cid" 559 + version = "0.11.1" 560 + source = "registry+https://github.com/rust-lang/crates.io-index" 561 + checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" 562 + dependencies = [ 563 + "core2", 564 + "multibase", 565 + "multihash", 566 + "serde", 567 + "serde_bytes", 568 + "unsigned-varint", 569 + ] 570 + 571 + [[package]] 572 + name = "color_quant" 573 + version = "1.1.0" 574 + source = "registry+https://github.com/rust-lang/crates.io-index" 575 + checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 576 + 577 + [[package]] 578 + name = "colored" 579 + version = "3.0.0" 580 + source = "registry+https://github.com/rust-lang/crates.io-index" 581 + checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" 582 + dependencies = [ 583 + "windows-sys 0.59.0", 584 + ] 585 + 586 + [[package]] 587 + name = "combine" 588 + version = "4.6.7" 589 + source = "registry+https://github.com/rust-lang/crates.io-index" 590 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 591 + dependencies = [ 592 + "bytes", 593 + "futures-core", 594 + "memchr", 595 + "pin-project-lite", 596 + "tokio", 597 + "tokio-util", 598 + ] 599 + 600 + [[package]] 601 + name = "compression-codecs" 602 + version = "0.4.31" 603 + source = "registry+https://github.com/rust-lang/crates.io-index" 604 + checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" 605 + dependencies = [ 606 + "compression-core", 607 + "flate2", 608 + "memchr", 609 + ] 610 + 611 + [[package]] 612 + name = "compression-core" 613 + version = "0.4.29" 614 + source = "registry+https://github.com/rust-lang/crates.io-index" 615 + checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" 616 + 617 + [[package]] 618 + name = "const-oid" 619 + version = "0.9.6" 620 + source = "registry+https://github.com/rust-lang/crates.io-index" 621 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 622 + 623 + [[package]] 624 + name = "const-str" 625 + version = "0.4.3" 626 + source = "registry+https://github.com/rust-lang/crates.io-index" 627 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 628 + 629 + [[package]] 630 + name = "cordyceps" 631 + version = "0.3.4" 632 + source = "registry+https://github.com/rust-lang/crates.io-index" 633 + checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" 634 + dependencies = [ 635 + "loom", 636 + "tracing", 637 + ] 638 + 639 + [[package]] 640 + name = "core-foundation" 641 + version = "0.9.4" 642 + source = "registry+https://github.com/rust-lang/crates.io-index" 643 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 644 + dependencies = [ 645 + "core-foundation-sys", 646 + "libc", 647 + ] 648 + 649 + [[package]] 650 + name = "core-foundation" 651 + version = "0.10.1" 652 + source = "registry+https://github.com/rust-lang/crates.io-index" 653 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 654 + dependencies = [ 655 + "core-foundation-sys", 656 + "libc", 657 + ] 658 + 659 + [[package]] 660 + name = "core-foundation-sys" 661 + version = "0.8.7" 662 + source = "registry+https://github.com/rust-lang/crates.io-index" 663 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 664 + 665 + [[package]] 666 + name = "core2" 667 + version = "0.4.0" 668 + source = "registry+https://github.com/rust-lang/crates.io-index" 669 + checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 670 + dependencies = [ 671 + "memchr", 672 + ] 673 + 674 + [[package]] 675 + name = "cpufeatures" 676 + version = "0.2.17" 677 + source = "registry+https://github.com/rust-lang/crates.io-index" 678 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 679 + dependencies = [ 680 + "libc", 681 + ] 682 + 683 + [[package]] 684 + name = "crc32fast" 685 + version = "1.5.0" 686 + source = "registry+https://github.com/rust-lang/crates.io-index" 687 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 688 + dependencies = [ 689 + "cfg-if", 690 + ] 691 + 692 + [[package]] 693 + name = "crossbeam-deque" 694 + version = "0.8.6" 695 + source = "registry+https://github.com/rust-lang/crates.io-index" 696 + checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 697 + dependencies = [ 698 + "crossbeam-epoch", 699 + "crossbeam-utils", 700 + ] 701 + 702 + [[package]] 703 + name = "crossbeam-epoch" 704 + version = "0.9.18" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 707 + dependencies = [ 708 + "crossbeam-utils", 709 + ] 710 + 711 + [[package]] 712 + name = "crossbeam-utils" 713 + version = "0.8.21" 714 + source = "registry+https://github.com/rust-lang/crates.io-index" 715 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 716 + 717 + [[package]] 718 + name = "crunchy" 719 + version = "0.2.4" 720 + source = "registry+https://github.com/rust-lang/crates.io-index" 721 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" 722 + 723 + [[package]] 724 + name = "crypto-bigint" 725 + version = "0.5.5" 726 + source = "registry+https://github.com/rust-lang/crates.io-index" 727 + checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 728 + dependencies = [ 729 + "generic-array", 730 + "rand_core 0.6.4", 731 + "subtle", 732 + "zeroize", 733 + ] 734 + 735 + [[package]] 736 + name = "crypto-common" 737 + version = "0.1.6" 738 + source = "registry+https://github.com/rust-lang/crates.io-index" 739 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 740 + dependencies = [ 741 + "generic-array", 742 + "typenum", 743 + ] 744 + 745 + [[package]] 746 + name = "darling" 747 + version = "0.21.3" 748 + source = "registry+https://github.com/rust-lang/crates.io-index" 749 + checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" 750 + dependencies = [ 751 + "darling_core", 752 + "darling_macro", 753 + ] 754 + 755 + [[package]] 756 + name = "darling_core" 757 + version = "0.21.3" 758 + source = "registry+https://github.com/rust-lang/crates.io-index" 759 + checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" 760 + dependencies = [ 761 + "fnv", 762 + "ident_case", 763 + "proc-macro2", 764 + "quote", 765 + "strsim", 766 + "syn 2.0.108", 767 + ] 768 + 769 + [[package]] 770 + name = "darling_macro" 771 + version = "0.21.3" 772 + source = "registry+https://github.com/rust-lang/crates.io-index" 773 + checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" 774 + dependencies = [ 775 + "darling_core", 776 + "quote", 777 + "syn 2.0.108", 778 + ] 779 + 780 + [[package]] 781 + name = "dashmap" 782 + version = "6.1.0" 783 + source = "registry+https://github.com/rust-lang/crates.io-index" 784 + checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 785 + dependencies = [ 786 + "cfg-if", 787 + "crossbeam-utils", 788 + "hashbrown 0.14.5", 789 + "lock_api", 790 + "once_cell", 791 + "parking_lot_core", 792 + ] 793 + 794 + [[package]] 795 + name = "data-encoding" 796 + version = "2.9.0" 797 + source = "registry+https://github.com/rust-lang/crates.io-index" 798 + checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" 799 + 800 + [[package]] 801 + name = "data-encoding-macro" 802 + version = "0.1.18" 803 + source = "registry+https://github.com/rust-lang/crates.io-index" 804 + checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" 805 + dependencies = [ 806 + "data-encoding", 807 + "data-encoding-macro-internal", 808 + ] 809 + 810 + [[package]] 811 + name = "data-encoding-macro-internal" 812 + version = "0.1.16" 813 + source = "registry+https://github.com/rust-lang/crates.io-index" 814 + checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 815 + dependencies = [ 816 + "data-encoding", 817 + "syn 2.0.108", 818 + ] 819 + 820 + [[package]] 821 + name = "deflate" 822 + version = "1.0.0" 823 + source = "registry+https://github.com/rust-lang/crates.io-index" 824 + checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" 825 + dependencies = [ 826 + "adler32", 827 + "gzip-header", 828 + ] 829 + 830 + [[package]] 831 + name = "der" 832 + version = "0.7.10" 833 + source = "registry+https://github.com/rust-lang/crates.io-index" 834 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 835 + dependencies = [ 836 + "const-oid", 837 + "pem-rfc7468", 838 + "zeroize", 839 + ] 840 + 841 + [[package]] 842 + name = "deranged" 843 + version = "0.5.4" 844 + source = "registry+https://github.com/rust-lang/crates.io-index" 845 + checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" 846 + dependencies = [ 847 + "powerfmt", 848 + ] 849 + 850 + [[package]] 851 + name = "derive_more" 852 + version = "1.0.0" 853 + source = "registry+https://github.com/rust-lang/crates.io-index" 854 + checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" 855 + dependencies = [ 856 + "derive_more-impl", 857 + ] 858 + 859 + [[package]] 860 + name = "derive_more-impl" 861 + version = "1.0.0" 862 + source = "registry+https://github.com/rust-lang/crates.io-index" 863 + checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" 864 + dependencies = [ 865 + "proc-macro2", 866 + "quote", 867 + "syn 2.0.108", 868 + "unicode-xid", 869 + ] 870 + 871 + [[package]] 872 + name = "diatomic-waker" 873 + version = "0.2.3" 874 + source = "registry+https://github.com/rust-lang/crates.io-index" 875 + checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" 876 + 877 + [[package]] 878 + name = "digest" 879 + version = "0.10.7" 880 + source = "registry+https://github.com/rust-lang/crates.io-index" 881 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 882 + dependencies = [ 883 + "block-buffer", 884 + "const-oid", 885 + "crypto-common", 886 + "subtle", 887 + ] 888 + 889 + [[package]] 890 + name = "displaydoc" 891 + version = "0.2.5" 892 + source = "registry+https://github.com/rust-lang/crates.io-index" 893 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 894 + dependencies = [ 895 + "proc-macro2", 896 + "quote", 897 + "syn 2.0.108", 898 + ] 899 + 900 + [[package]] 901 + name = "dotenvy" 902 + version = "0.15.7" 903 + source = "registry+https://github.com/rust-lang/crates.io-index" 904 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 905 + 906 + [[package]] 907 + name = "ecdsa" 908 + version = "0.16.9" 909 + source = "registry+https://github.com/rust-lang/crates.io-index" 910 + checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 911 + dependencies = [ 912 + "der", 913 + "digest", 914 + "elliptic-curve", 915 + "rfc6979", 916 + "signature", 917 + "spki", 918 + ] 919 + 920 + [[package]] 921 + name = "either" 922 + version = "1.15.0" 923 + source = "registry+https://github.com/rust-lang/crates.io-index" 924 + checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 925 + 926 + [[package]] 927 + name = "elliptic-curve" 928 + version = "0.13.8" 929 + source = "registry+https://github.com/rust-lang/crates.io-index" 930 + checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 931 + dependencies = [ 932 + "base16ct", 933 + "crypto-bigint", 934 + "digest", 935 + "ff", 936 + "generic-array", 937 + "group", 938 + "pem-rfc7468", 939 + "pkcs8", 940 + "rand_core 0.6.4", 941 + "sec1", 942 + "subtle", 943 + "zeroize", 944 + ] 945 + 946 + [[package]] 947 + name = "encoding_rs" 948 + version = "0.8.35" 949 + source = "registry+https://github.com/rust-lang/crates.io-index" 950 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 951 + dependencies = [ 952 + "cfg-if", 953 + ] 954 + 955 + [[package]] 956 + name = "enum-as-inner" 957 + version = "0.6.1" 958 + source = "registry+https://github.com/rust-lang/crates.io-index" 959 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 960 + dependencies = [ 961 + "heck 0.5.0", 962 + "proc-macro2", 963 + "quote", 964 + "syn 2.0.108", 965 + ] 966 + 967 + [[package]] 968 + name = "equator" 969 + version = "0.4.2" 970 + source = "registry+https://github.com/rust-lang/crates.io-index" 971 + checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" 972 + dependencies = [ 973 + "equator-macro", 974 + ] 975 + 976 + [[package]] 977 + name = "equator-macro" 978 + version = "0.4.2" 979 + source = "registry+https://github.com/rust-lang/crates.io-index" 980 + checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" 981 + dependencies = [ 982 + "proc-macro2", 983 + "quote", 984 + "syn 2.0.108", 985 + ] 986 + 987 + [[package]] 988 + name = "equivalent" 989 + version = "1.0.2" 990 + source = "registry+https://github.com/rust-lang/crates.io-index" 991 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 992 + 993 + [[package]] 994 + name = "errno" 995 + version = "0.3.14" 996 + source = "registry+https://github.com/rust-lang/crates.io-index" 997 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 998 + dependencies = [ 999 + "libc", 1000 + "windows-sys 0.61.2", 1001 + ] 1002 + 1003 + [[package]] 1004 + name = "exr" 1005 + version = "1.73.0" 1006 + source = "registry+https://github.com/rust-lang/crates.io-index" 1007 + checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" 1008 + dependencies = [ 1009 + "bit_field", 1010 + "half", 1011 + "lebe", 1012 + "miniz_oxide", 1013 + "rayon-core", 1014 + "smallvec", 1015 + "zune-inflate", 1016 + ] 1017 + 1018 + [[package]] 1019 + name = "fastrand" 1020 + version = "2.3.0" 1021 + source = "registry+https://github.com/rust-lang/crates.io-index" 1022 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 1023 + 1024 + [[package]] 1025 + name = "fax" 1026 + version = "0.2.6" 1027 + source = "registry+https://github.com/rust-lang/crates.io-index" 1028 + checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" 1029 + dependencies = [ 1030 + "fax_derive", 1031 + ] 1032 + 1033 + [[package]] 1034 + name = "fax_derive" 1035 + version = "0.2.0" 1036 + source = "registry+https://github.com/rust-lang/crates.io-index" 1037 + checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" 1038 + dependencies = [ 1039 + "proc-macro2", 1040 + "quote", 1041 + "syn 2.0.108", 1042 + ] 1043 + 1044 + [[package]] 1045 + name = "fdeflate" 1046 + version = "0.3.7" 1047 + source = "registry+https://github.com/rust-lang/crates.io-index" 1048 + checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" 1049 + dependencies = [ 1050 + "simd-adler32", 1051 + ] 1052 + 1053 + [[package]] 1054 + name = "ff" 1055 + version = "0.13.1" 1056 + source = "registry+https://github.com/rust-lang/crates.io-index" 1057 + checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 1058 + dependencies = [ 1059 + "rand_core 0.6.4", 1060 + "subtle", 1061 + ] 1062 + 1063 + [[package]] 1064 + name = "filetime" 1065 + version = "0.2.26" 1066 + source = "registry+https://github.com/rust-lang/crates.io-index" 1067 + checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" 1068 + dependencies = [ 1069 + "cfg-if", 1070 + "libc", 1071 + "libredox", 1072 + "windows-sys 0.60.2", 1073 + ] 1074 + 1075 + [[package]] 1076 + name = "find-msvc-tools" 1077 + version = "0.1.4" 1078 + source = "registry+https://github.com/rust-lang/crates.io-index" 1079 + checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" 1080 + 1081 + [[package]] 1082 + name = "flate2" 1083 + version = "1.1.4" 1084 + source = "registry+https://github.com/rust-lang/crates.io-index" 1085 + checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" 1086 + dependencies = [ 1087 + "crc32fast", 1088 + "miniz_oxide", 1089 + ] 1090 + 1091 + [[package]] 1092 + name = "fnv" 1093 + version = "1.0.7" 1094 + source = "registry+https://github.com/rust-lang/crates.io-index" 1095 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 1096 + 1097 + [[package]] 1098 + name = "foreign-types" 1099 + version = "0.3.2" 1100 + source = "registry+https://github.com/rust-lang/crates.io-index" 1101 + checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 1102 + dependencies = [ 1103 + "foreign-types-shared", 1104 + ] 1105 + 1106 + [[package]] 1107 + name = "foreign-types-shared" 1108 + version = "0.1.1" 1109 + source = "registry+https://github.com/rust-lang/crates.io-index" 1110 + checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 1111 + 1112 + [[package]] 1113 + name = "form_urlencoded" 1114 + version = "1.2.2" 1115 + source = "registry+https://github.com/rust-lang/crates.io-index" 1116 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 1117 + dependencies = [ 1118 + "percent-encoding", 1119 + ] 1120 + 1121 + [[package]] 1122 + name = "futf" 1123 + version = "0.1.5" 1124 + source = "registry+https://github.com/rust-lang/crates.io-index" 1125 + checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" 1126 + dependencies = [ 1127 + "mac", 1128 + "new_debug_unreachable", 1129 + ] 1130 + 1131 + [[package]] 1132 + name = "futures" 1133 + version = "0.3.31" 1134 + source = "registry+https://github.com/rust-lang/crates.io-index" 1135 + checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 1136 + dependencies = [ 1137 + "futures-channel", 1138 + "futures-core", 1139 + "futures-executor", 1140 + "futures-io", 1141 + "futures-sink", 1142 + "futures-task", 1143 + "futures-util", 1144 + ] 1145 + 1146 + [[package]] 1147 + name = "futures-buffered" 1148 + version = "0.2.12" 1149 + source = "registry+https://github.com/rust-lang/crates.io-index" 1150 + checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd" 1151 + dependencies = [ 1152 + "cordyceps", 1153 + "diatomic-waker", 1154 + "futures-core", 1155 + "pin-project-lite", 1156 + "spin 0.10.0", 1157 + ] 1158 + 1159 + [[package]] 1160 + name = "futures-channel" 1161 + version = "0.3.31" 1162 + source = "registry+https://github.com/rust-lang/crates.io-index" 1163 + checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 1164 + dependencies = [ 1165 + "futures-core", 1166 + "futures-sink", 1167 + ] 1168 + 1169 + [[package]] 1170 + name = "futures-core" 1171 + version = "0.3.31" 1172 + source = "registry+https://github.com/rust-lang/crates.io-index" 1173 + checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 1174 + 1175 + [[package]] 1176 + name = "futures-executor" 1177 + version = "0.3.31" 1178 + source = "registry+https://github.com/rust-lang/crates.io-index" 1179 + checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 1180 + dependencies = [ 1181 + "futures-core", 1182 + "futures-task", 1183 + "futures-util", 1184 + ] 1185 + 1186 + [[package]] 1187 + name = "futures-io" 1188 + version = "0.3.31" 1189 + source = "registry+https://github.com/rust-lang/crates.io-index" 1190 + checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 1191 + 1192 + [[package]] 1193 + name = "futures-lite" 1194 + version = "2.6.1" 1195 + source = "registry+https://github.com/rust-lang/crates.io-index" 1196 + checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" 1197 + dependencies = [ 1198 + "fastrand", 1199 + "futures-core", 1200 + "futures-io", 1201 + "parking", 1202 + "pin-project-lite", 1203 + ] 1204 + 1205 + [[package]] 1206 + name = "futures-macro" 1207 + version = "0.3.31" 1208 + source = "registry+https://github.com/rust-lang/crates.io-index" 1209 + checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 1210 + dependencies = [ 1211 + "proc-macro2", 1212 + "quote", 1213 + "syn 2.0.108", 1214 + ] 1215 + 1216 + [[package]] 1217 + name = "futures-sink" 1218 + version = "0.3.31" 1219 + source = "registry+https://github.com/rust-lang/crates.io-index" 1220 + checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 1221 + 1222 + [[package]] 1223 + name = "futures-task" 1224 + version = "0.3.31" 1225 + source = "registry+https://github.com/rust-lang/crates.io-index" 1226 + checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 1227 + 1228 + [[package]] 1229 + name = "futures-timer" 1230 + version = "3.0.3" 1231 + source = "registry+https://github.com/rust-lang/crates.io-index" 1232 + checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" 1233 + 1234 + [[package]] 1235 + name = "futures-util" 1236 + version = "0.3.31" 1237 + source = "registry+https://github.com/rust-lang/crates.io-index" 1238 + checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 1239 + dependencies = [ 1240 + "futures-channel", 1241 + "futures-core", 1242 + "futures-io", 1243 + "futures-macro", 1244 + "futures-sink", 1245 + "futures-task", 1246 + "memchr", 1247 + "pin-project-lite", 1248 + "pin-utils", 1249 + "slab", 1250 + ] 1251 + 1252 + [[package]] 1253 + name = "generator" 1254 + version = "0.8.7" 1255 + source = "registry+https://github.com/rust-lang/crates.io-index" 1256 + checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" 1257 + dependencies = [ 1258 + "cc", 1259 + "cfg-if", 1260 + "libc", 1261 + "log", 1262 + "rustversion", 1263 + "windows", 1264 + ] 1265 + 1266 + [[package]] 1267 + name = "generic-array" 1268 + version = "0.14.9" 1269 + source = "registry+https://github.com/rust-lang/crates.io-index" 1270 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 1271 + dependencies = [ 1272 + "typenum", 1273 + "version_check", 1274 + "zeroize", 1275 + ] 1276 + 1277 + [[package]] 1278 + name = "getrandom" 1279 + version = "0.2.16" 1280 + source = "registry+https://github.com/rust-lang/crates.io-index" 1281 + checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 1282 + dependencies = [ 1283 + "cfg-if", 1284 + "js-sys", 1285 + "libc", 1286 + "wasi", 1287 + "wasm-bindgen", 1288 + ] 1289 + 1290 + [[package]] 1291 + name = "getrandom" 1292 + version = "0.3.4" 1293 + source = "registry+https://github.com/rust-lang/crates.io-index" 1294 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 1295 + dependencies = [ 1296 + "cfg-if", 1297 + "js-sys", 1298 + "libc", 1299 + "r-efi", 1300 + "wasip2", 1301 + "wasm-bindgen", 1302 + ] 1303 + 1304 + [[package]] 1305 + name = "gif" 1306 + version = "0.13.3" 1307 + source = "registry+https://github.com/rust-lang/crates.io-index" 1308 + checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" 1309 + dependencies = [ 1310 + "color_quant", 1311 + "weezl", 1312 + ] 1313 + 1314 + [[package]] 1315 + name = "gimli" 1316 + version = "0.32.3" 1317 + source = "registry+https://github.com/rust-lang/crates.io-index" 1318 + checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" 1319 + 1320 + [[package]] 1321 + name = "governor" 1322 + version = "0.8.1" 1323 + source = "registry+https://github.com/rust-lang/crates.io-index" 1324 + checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" 1325 + dependencies = [ 1326 + "cfg-if", 1327 + "dashmap", 1328 + "futures-sink", 1329 + "futures-timer", 1330 + "futures-util", 1331 + "getrandom 0.3.4", 1332 + "no-std-compat", 1333 + "nonzero_ext", 1334 + "parking_lot", 1335 + "portable-atomic", 1336 + "quanta", 1337 + "rand 0.9.2", 1338 + "smallvec", 1339 + "spinning_top", 1340 + "web-time", 1341 + ] 1342 + 1343 + [[package]] 1344 + name = "group" 1345 + version = "0.13.0" 1346 + source = "registry+https://github.com/rust-lang/crates.io-index" 1347 + checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 1348 + dependencies = [ 1349 + "ff", 1350 + "rand_core 0.6.4", 1351 + "subtle", 1352 + ] 1353 + 1354 + [[package]] 1355 + name = "gzip-header" 1356 + version = "1.0.0" 1357 + source = "registry+https://github.com/rust-lang/crates.io-index" 1358 + checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" 1359 + dependencies = [ 1360 + "crc32fast", 1361 + ] 1362 + 1363 + [[package]] 1364 + name = "h2" 1365 + version = "0.4.12" 1366 + source = "registry+https://github.com/rust-lang/crates.io-index" 1367 + checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1368 + dependencies = [ 1369 + "atomic-waker", 1370 + "bytes", 1371 + "fnv", 1372 + "futures-core", 1373 + "futures-sink", 1374 + "http", 1375 + "indexmap", 1376 + "slab", 1377 + "tokio", 1378 + "tokio-util", 1379 + "tracing", 1380 + ] 1381 + 1382 + [[package]] 1383 + name = "half" 1384 + version = "2.7.1" 1385 + source = "registry+https://github.com/rust-lang/crates.io-index" 1386 + checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" 1387 + dependencies = [ 1388 + "cfg-if", 1389 + "crunchy", 1390 + "zerocopy", 1391 + ] 1392 + 1393 + [[package]] 1394 + name = "hashbrown" 1395 + version = "0.14.5" 1396 + source = "registry+https://github.com/rust-lang/crates.io-index" 1397 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1398 + 1399 + [[package]] 1400 + name = "hashbrown" 1401 + version = "0.16.0" 1402 + source = "registry+https://github.com/rust-lang/crates.io-index" 1403 + checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1404 + 1405 + [[package]] 1406 + name = "heck" 1407 + version = "0.4.1" 1408 + source = "registry+https://github.com/rust-lang/crates.io-index" 1409 + checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 1410 + 1411 + [[package]] 1412 + name = "heck" 1413 + version = "0.5.0" 1414 + source = "registry+https://github.com/rust-lang/crates.io-index" 1415 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1416 + 1417 + [[package]] 1418 + name = "hermit-abi" 1419 + version = "0.5.2" 1420 + source = "registry+https://github.com/rust-lang/crates.io-index" 1421 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1422 + 1423 + [[package]] 1424 + name = "hex_fmt" 1425 + version = "0.3.0" 1426 + source = "registry+https://github.com/rust-lang/crates.io-index" 1427 + checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" 1428 + 1429 + [[package]] 1430 + name = "hickory-proto" 1431 + version = "0.24.4" 1432 + source = "registry+https://github.com/rust-lang/crates.io-index" 1433 + checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" 1434 + dependencies = [ 1435 + "async-trait", 1436 + "cfg-if", 1437 + "data-encoding", 1438 + "enum-as-inner", 1439 + "futures-channel", 1440 + "futures-io", 1441 + "futures-util", 1442 + "idna", 1443 + "ipnet", 1444 + "once_cell", 1445 + "rand 0.8.5", 1446 + "thiserror 1.0.69", 1447 + "tinyvec", 1448 + "tokio", 1449 + "tracing", 1450 + "url", 1451 + ] 1452 + 1453 + [[package]] 1454 + name = "hickory-resolver" 1455 + version = "0.24.4" 1456 + source = "registry+https://github.com/rust-lang/crates.io-index" 1457 + checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" 1458 + dependencies = [ 1459 + "cfg-if", 1460 + "futures-util", 1461 + "hickory-proto", 1462 + "ipconfig", 1463 + "lru-cache", 1464 + "once_cell", 1465 + "parking_lot", 1466 + "rand 0.8.5", 1467 + "resolv-conf", 1468 + "smallvec", 1469 + "thiserror 1.0.69", 1470 + "tokio", 1471 + "tracing", 1472 + ] 1473 + 1474 + [[package]] 1475 + name = "hmac" 1476 + version = "0.12.1" 1477 + source = "registry+https://github.com/rust-lang/crates.io-index" 1478 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1479 + dependencies = [ 1480 + "digest", 1481 + ] 1482 + 1483 + [[package]] 1484 + name = "home" 1485 + version = "0.5.12" 1486 + source = "registry+https://github.com/rust-lang/crates.io-index" 1487 + checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" 1488 + dependencies = [ 1489 + "windows-sys 0.61.2", 1490 + ] 1491 + 1492 + [[package]] 1493 + name = "html5ever" 1494 + version = "0.27.0" 1495 + source = "registry+https://github.com/rust-lang/crates.io-index" 1496 + checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" 1497 + dependencies = [ 1498 + "log", 1499 + "mac", 1500 + "markup5ever", 1501 + "proc-macro2", 1502 + "quote", 1503 + "syn 2.0.108", 1504 + ] 1505 + 1506 + [[package]] 1507 + name = "http" 1508 + version = "1.3.1" 1509 + source = "registry+https://github.com/rust-lang/crates.io-index" 1510 + checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 1511 + dependencies = [ 1512 + "bytes", 1513 + "fnv", 1514 + "itoa", 1515 + ] 1516 + 1517 + [[package]] 1518 + name = "http-body" 1519 + version = "1.0.1" 1520 + source = "registry+https://github.com/rust-lang/crates.io-index" 1521 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 1522 + dependencies = [ 1523 + "bytes", 1524 + "http", 1525 + ] 1526 + 1527 + [[package]] 1528 + name = "http-body-util" 1529 + version = "0.1.3" 1530 + source = "registry+https://github.com/rust-lang/crates.io-index" 1531 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 1532 + dependencies = [ 1533 + "bytes", 1534 + "futures-core", 1535 + "http", 1536 + "http-body", 1537 + "pin-project-lite", 1538 + ] 1539 + 1540 + [[package]] 1541 + name = "httparse" 1542 + version = "1.10.1" 1543 + source = "registry+https://github.com/rust-lang/crates.io-index" 1544 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1545 + 1546 + [[package]] 1547 + name = "httpdate" 1548 + version = "1.0.3" 1549 + source = "registry+https://github.com/rust-lang/crates.io-index" 1550 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1551 + 1552 + [[package]] 1553 + name = "hyper" 1554 + version = "1.7.0" 1555 + source = "registry+https://github.com/rust-lang/crates.io-index" 1556 + checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 1557 + dependencies = [ 1558 + "atomic-waker", 1559 + "bytes", 1560 + "futures-channel", 1561 + "futures-core", 1562 + "h2", 1563 + "http", 1564 + "http-body", 1565 + "httparse", 1566 + "httpdate", 1567 + "itoa", 1568 + "pin-project-lite", 1569 + "pin-utils", 1570 + "smallvec", 1571 + "tokio", 1572 + "want", 1573 + ] 1574 + 1575 + [[package]] 1576 + name = "hyper-rustls" 1577 + version = "0.27.7" 1578 + source = "registry+https://github.com/rust-lang/crates.io-index" 1579 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1580 + dependencies = [ 1581 + "http", 1582 + "hyper", 1583 + "hyper-util", 1584 + "rustls", 1585 + "rustls-pki-types", 1586 + "tokio", 1587 + "tokio-rustls", 1588 + "tower-service", 1589 + "webpki-roots", 1590 + ] 1591 + 1592 + [[package]] 1593 + name = "hyper-tls" 1594 + version = "0.6.0" 1595 + source = "registry+https://github.com/rust-lang/crates.io-index" 1596 + checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" 1597 + dependencies = [ 1598 + "bytes", 1599 + "http-body-util", 1600 + "hyper", 1601 + "hyper-util", 1602 + "native-tls", 1603 + "tokio", 1604 + "tokio-native-tls", 1605 + "tower-service", 1606 + ] 1607 + 1608 + [[package]] 1609 + name = "hyper-util" 1610 + version = "0.1.17" 1611 + source = "registry+https://github.com/rust-lang/crates.io-index" 1612 + checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1613 + dependencies = [ 1614 + "base64 0.22.1", 1615 + "bytes", 1616 + "futures-channel", 1617 + "futures-core", 1618 + "futures-util", 1619 + "http", 1620 + "http-body", 1621 + "hyper", 1622 + "ipnet", 1623 + "libc", 1624 + "percent-encoding", 1625 + "pin-project-lite", 1626 + "socket2 0.6.1", 1627 + "system-configuration", 1628 + "tokio", 1629 + "tower-service", 1630 + "tracing", 1631 + "windows-registry", 1632 + ] 1633 + 1634 + [[package]] 1635 + name = "iana-time-zone" 1636 + version = "0.1.64" 1637 + source = "registry+https://github.com/rust-lang/crates.io-index" 1638 + checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 1639 + dependencies = [ 1640 + "android_system_properties", 1641 + "core-foundation-sys", 1642 + "iana-time-zone-haiku", 1643 + "js-sys", 1644 + "log", 1645 + "wasm-bindgen", 1646 + "windows-core 0.62.2", 1647 + ] 1648 + 1649 + [[package]] 1650 + name = "iana-time-zone-haiku" 1651 + version = "0.1.2" 1652 + source = "registry+https://github.com/rust-lang/crates.io-index" 1653 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 1654 + dependencies = [ 1655 + "cc", 1656 + ] 1657 + 1658 + [[package]] 1659 + name = "icu_collections" 1660 + version = "2.0.0" 1661 + source = "registry+https://github.com/rust-lang/crates.io-index" 1662 + checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" 1663 + dependencies = [ 1664 + "displaydoc", 1665 + "potential_utf", 1666 + "yoke", 1667 + "zerofrom", 1668 + "zerovec", 1669 + ] 1670 + 1671 + [[package]] 1672 + name = "icu_locale_core" 1673 + version = "2.0.0" 1674 + source = "registry+https://github.com/rust-lang/crates.io-index" 1675 + checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" 1676 + dependencies = [ 1677 + "displaydoc", 1678 + "litemap", 1679 + "tinystr", 1680 + "writeable", 1681 + "zerovec", 1682 + ] 1683 + 1684 + [[package]] 1685 + name = "icu_normalizer" 1686 + version = "2.0.0" 1687 + source = "registry+https://github.com/rust-lang/crates.io-index" 1688 + checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" 1689 + dependencies = [ 1690 + "displaydoc", 1691 + "icu_collections", 1692 + "icu_normalizer_data", 1693 + "icu_properties", 1694 + "icu_provider", 1695 + "smallvec", 1696 + "zerovec", 1697 + ] 1698 + 1699 + [[package]] 1700 + name = "icu_normalizer_data" 1701 + version = "2.0.0" 1702 + source = "registry+https://github.com/rust-lang/crates.io-index" 1703 + checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" 1704 + 1705 + [[package]] 1706 + name = "icu_properties" 1707 + version = "2.0.1" 1708 + source = "registry+https://github.com/rust-lang/crates.io-index" 1709 + checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" 1710 + dependencies = [ 1711 + "displaydoc", 1712 + "icu_collections", 1713 + "icu_locale_core", 1714 + "icu_properties_data", 1715 + "icu_provider", 1716 + "potential_utf", 1717 + "zerotrie", 1718 + "zerovec", 1719 + ] 1720 + 1721 + [[package]] 1722 + name = "icu_properties_data" 1723 + version = "2.0.1" 1724 + source = "registry+https://github.com/rust-lang/crates.io-index" 1725 + checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" 1726 + 1727 + [[package]] 1728 + name = "icu_provider" 1729 + version = "2.0.0" 1730 + source = "registry+https://github.com/rust-lang/crates.io-index" 1731 + checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" 1732 + dependencies = [ 1733 + "displaydoc", 1734 + "icu_locale_core", 1735 + "stable_deref_trait", 1736 + "tinystr", 1737 + "writeable", 1738 + "yoke", 1739 + "zerofrom", 1740 + "zerotrie", 1741 + "zerovec", 1742 + ] 1743 + 1744 + [[package]] 1745 + name = "ident_case" 1746 + version = "1.0.1" 1747 + source = "registry+https://github.com/rust-lang/crates.io-index" 1748 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1749 + 1750 + [[package]] 1751 + name = "idna" 1752 + version = "1.1.0" 1753 + source = "registry+https://github.com/rust-lang/crates.io-index" 1754 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 1755 + dependencies = [ 1756 + "idna_adapter", 1757 + "smallvec", 1758 + "utf8_iter", 1759 + ] 1760 + 1761 + [[package]] 1762 + name = "idna_adapter" 1763 + version = "1.2.1" 1764 + source = "registry+https://github.com/rust-lang/crates.io-index" 1765 + checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 1766 + dependencies = [ 1767 + "icu_normalizer", 1768 + "icu_properties", 1769 + ] 1770 + 1771 + [[package]] 1772 + name = "image" 1773 + version = "0.25.8" 1774 + source = "registry+https://github.com/rust-lang/crates.io-index" 1775 + checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" 1776 + dependencies = [ 1777 + "bytemuck", 1778 + "byteorder-lite", 1779 + "color_quant", 1780 + "exr", 1781 + "gif", 1782 + "image-webp", 1783 + "moxcms", 1784 + "num-traits", 1785 + "png", 1786 + "qoi", 1787 + "ravif", 1788 + "rayon", 1789 + "rgb", 1790 + "tiff", 1791 + "zune-core", 1792 + "zune-jpeg", 1793 + ] 1794 + 1795 + [[package]] 1796 + name = "image-webp" 1797 + version = "0.2.4" 1798 + source = "registry+https://github.com/rust-lang/crates.io-index" 1799 + checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" 1800 + dependencies = [ 1801 + "byteorder-lite", 1802 + "quick-error 2.0.1", 1803 + ] 1804 + 1805 + [[package]] 1806 + name = "image_hasher" 1807 + version = "3.0.0" 1808 + source = "registry+https://github.com/rust-lang/crates.io-index" 1809 + checksum = "7c191dc6138f559a0177b8413eaf2a37784d8e63c697e247aa3740930f1c9364" 1810 + dependencies = [ 1811 + "base64 0.22.1", 1812 + "image", 1813 + "rustdct", 1814 + "serde", 1815 + "transpose", 1816 + ] 1817 + 1818 + [[package]] 1819 + name = "imgref" 1820 + version = "1.12.0" 1821 + source = "registry+https://github.com/rust-lang/crates.io-index" 1822 + checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" 1823 + 1824 + [[package]] 1825 + name = "indexmap" 1826 + version = "2.12.0" 1827 + source = "registry+https://github.com/rust-lang/crates.io-index" 1828 + checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" 1829 + dependencies = [ 1830 + "equivalent", 1831 + "hashbrown 0.16.0", 1832 + ] 1833 + 1834 + [[package]] 1835 + name = "indoc" 1836 + version = "2.0.7" 1837 + source = "registry+https://github.com/rust-lang/crates.io-index" 1838 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" 1839 + dependencies = [ 1840 + "rustversion", 1841 + ] 1842 + 1843 + [[package]] 1844 + name = "interpolate_name" 1845 + version = "0.2.4" 1846 + source = "registry+https://github.com/rust-lang/crates.io-index" 1847 + checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" 1848 + dependencies = [ 1849 + "proc-macro2", 1850 + "quote", 1851 + "syn 2.0.108", 1852 + ] 1853 + 1854 + [[package]] 1855 + name = "ipconfig" 1856 + version = "0.3.2" 1857 + source = "registry+https://github.com/rust-lang/crates.io-index" 1858 + checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1859 + dependencies = [ 1860 + "socket2 0.5.10", 1861 + "widestring", 1862 + "windows-sys 0.48.0", 1863 + "winreg", 1864 + ] 1865 + 1866 + [[package]] 1867 + name = "ipld-core" 1868 + version = "0.4.2" 1869 + source = "registry+https://github.com/rust-lang/crates.io-index" 1870 + checksum = "104718b1cc124d92a6d01ca9c9258a7df311405debb3408c445a36452f9bf8db" 1871 + dependencies = [ 1872 + "cid", 1873 + "serde", 1874 + "serde_bytes", 1875 + ] 1876 + 1877 + [[package]] 1878 + name = "ipnet" 1879 + version = "2.11.0" 1880 + source = "registry+https://github.com/rust-lang/crates.io-index" 1881 + checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 1882 + 1883 + [[package]] 1884 + name = "iri-string" 1885 + version = "0.7.8" 1886 + source = "registry+https://github.com/rust-lang/crates.io-index" 1887 + checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 1888 + dependencies = [ 1889 + "memchr", 1890 + "serde", 1891 + ] 1892 + 1893 + [[package]] 1894 + name = "is_ci" 1895 + version = "1.2.0" 1896 + source = "registry+https://github.com/rust-lang/crates.io-index" 1897 + checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" 1898 + 1899 + [[package]] 1900 + name = "itertools" 1901 + version = "0.12.1" 1902 + source = "registry+https://github.com/rust-lang/crates.io-index" 1903 + checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" 1904 + dependencies = [ 1905 + "either", 1906 + ] 1907 + 1908 + [[package]] 1909 + name = "itertools" 1910 + version = "0.13.0" 1911 + source = "registry+https://github.com/rust-lang/crates.io-index" 1912 + checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 1913 + dependencies = [ 1914 + "either", 1915 + ] 1916 + 1917 + [[package]] 1918 + name = "itoa" 1919 + version = "1.0.15" 1920 + source = "registry+https://github.com/rust-lang/crates.io-index" 1921 + checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 1922 + 1923 + [[package]] 1924 + name = "jacquard" 1925 + version = "0.7.0" 1926 + dependencies = [ 1927 + "bon", 1928 + "bytes", 1929 + "getrandom 0.2.16", 1930 + "http", 1931 + "jacquard-api", 1932 + "jacquard-common", 1933 + "jacquard-derive", 1934 + "jacquard-identity", 1935 + "jacquard-oauth", 1936 + "jose-jwk", 1937 + "miette", 1938 + "p256", 1939 + "percent-encoding", 1940 + "rand_core 0.6.4", 1941 + "regex", 1942 + "reqwest", 1943 + "serde", 1944 + "serde_html_form", 1945 + "serde_ipld_dagcbor", 1946 + "serde_json", 1947 + "smol_str", 1948 + "thiserror 2.0.17", 1949 + "tokio", 1950 + "trait-variant", 1951 + "url", 1952 + "webpage", 1953 + ] 1954 + 1955 + [[package]] 1956 + name = "jacquard-api" 1957 + version = "0.7.1" 1958 + dependencies = [ 1959 + "bon", 1960 + "bytes", 1961 + "jacquard-common", 1962 + "jacquard-derive", 1963 + "miette", 1964 + "serde", 1965 + "serde_ipld_dagcbor", 1966 + "thiserror 2.0.17", 1967 + ] 1968 + 1969 + [[package]] 1970 + name = "jacquard-common" 1971 + version = "0.7.0" 1972 + dependencies = [ 1973 + "base64 0.22.1", 1974 + "bon", 1975 + "bytes", 1976 + "chrono", 1977 + "ciborium", 1978 + "cid", 1979 + "futures", 1980 + "getrandom 0.2.16", 1981 + "getrandom 0.3.4", 1982 + "http", 1983 + "ipld-core", 1984 + "k256", 1985 + "langtag", 1986 + "miette", 1987 + "multibase", 1988 + "multihash", 1989 + "n0-future", 1990 + "ouroboros", 1991 + "p256", 1992 + "rand 0.9.2", 1993 + "regex", 1994 + "reqwest", 1995 + "serde", 1996 + "serde_html_form", 1997 + "serde_ipld_dagcbor", 1998 + "serde_json", 1999 + "signature", 2000 + "smol_str", 2001 + "thiserror 2.0.17", 2002 + "tokio", 2003 + "tokio-tungstenite-wasm", 2004 + "tokio-util", 2005 + "trait-variant", 2006 + "url", 2007 + ] 2008 + 2009 + [[package]] 2010 + name = "jacquard-derive" 2011 + version = "0.7.0" 2012 + dependencies = [ 2013 + "proc-macro2", 2014 + "quote", 2015 + "syn 2.0.108", 2016 + ] 2017 + 2018 + [[package]] 2019 + name = "jacquard-identity" 2020 + version = "0.7.0" 2021 + dependencies = [ 2022 + "bon", 2023 + "bytes", 2024 + "hickory-resolver", 2025 + "http", 2026 + "jacquard-api", 2027 + "jacquard-common", 2028 + "miette", 2029 + "percent-encoding", 2030 + "reqwest", 2031 + "serde", 2032 + "serde_html_form", 2033 + "serde_json", 2034 + "thiserror 2.0.17", 2035 + "tokio", 2036 + "trait-variant", 2037 + "url", 2038 + "urlencoding", 2039 + ] 2040 + 2041 + [[package]] 2042 + name = "jacquard-oauth" 2043 + version = "0.7.0" 2044 + dependencies = [ 2045 + "base64 0.22.1", 2046 + "bytes", 2047 + "chrono", 2048 + "dashmap", 2049 + "elliptic-curve", 2050 + "http", 2051 + "jacquard-common", 2052 + "jacquard-identity", 2053 + "jose-jwa", 2054 + "jose-jwk", 2055 + "miette", 2056 + "p256", 2057 + "rand 0.8.5", 2058 + "rand_core 0.6.4", 2059 + "reqwest", 2060 + "rouille", 2061 + "serde", 2062 + "serde_html_form", 2063 + "serde_json", 2064 + "sha2", 2065 + "signature", 2066 + "smol_str", 2067 + "thiserror 2.0.17", 2068 + "tokio", 2069 + "trait-variant", 2070 + "url", 2071 + "webbrowser", 2072 + ] 2073 + 2074 + [[package]] 2075 + name = "jni" 2076 + version = "0.21.1" 2077 + source = "registry+https://github.com/rust-lang/crates.io-index" 2078 + checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 2079 + dependencies = [ 2080 + "cesu8", 2081 + "cfg-if", 2082 + "combine", 2083 + "jni-sys", 2084 + "log", 2085 + "thiserror 1.0.69", 2086 + "walkdir", 2087 + "windows-sys 0.45.0", 2088 + ] 2089 + 2090 + [[package]] 2091 + name = "jni-sys" 2092 + version = "0.3.0" 2093 + source = "registry+https://github.com/rust-lang/crates.io-index" 2094 + checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 2095 + 2096 + [[package]] 2097 + name = "jobserver" 2098 + version = "0.1.34" 2099 + source = "registry+https://github.com/rust-lang/crates.io-index" 2100 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 2101 + dependencies = [ 2102 + "getrandom 0.3.4", 2103 + "libc", 2104 + ] 2105 + 2106 + [[package]] 2107 + name = "jose-b64" 2108 + version = "0.1.2" 2109 + source = "registry+https://github.com/rust-lang/crates.io-index" 2110 + checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 2111 + dependencies = [ 2112 + "base64ct", 2113 + "serde", 2114 + "subtle", 2115 + "zeroize", 2116 + ] 2117 + 2118 + [[package]] 2119 + name = "jose-jwa" 2120 + version = "0.1.2" 2121 + source = "registry+https://github.com/rust-lang/crates.io-index" 2122 + checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 2123 + dependencies = [ 2124 + "serde", 2125 + ] 2126 + 2127 + [[package]] 2128 + name = "jose-jwk" 2129 + version = "0.1.2" 2130 + source = "registry+https://github.com/rust-lang/crates.io-index" 2131 + checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 2132 + dependencies = [ 2133 + "jose-b64", 2134 + "jose-jwa", 2135 + "p256", 2136 + "p384", 2137 + "rsa", 2138 + "serde", 2139 + "zeroize", 2140 + ] 2141 + 2142 + [[package]] 2143 + name = "js-sys" 2144 + version = "0.3.81" 2145 + source = "registry+https://github.com/rust-lang/crates.io-index" 2146 + checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" 2147 + dependencies = [ 2148 + "once_cell", 2149 + "wasm-bindgen", 2150 + ] 2151 + 2152 + [[package]] 2153 + name = "k256" 2154 + version = "0.13.4" 2155 + source = "registry+https://github.com/rust-lang/crates.io-index" 2156 + checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" 2157 + dependencies = [ 2158 + "cfg-if", 2159 + "ecdsa", 2160 + "elliptic-curve", 2161 + "sha2", 2162 + ] 2163 + 2164 + [[package]] 2165 + name = "langtag" 2166 + version = "0.4.0" 2167 + source = "registry+https://github.com/rust-lang/crates.io-index" 2168 + checksum = "9ecb4c689a30e48ebeaa14237f34037e300dd072e6ad21a9ec72e810ff3c6600" 2169 + dependencies = [ 2170 + "serde", 2171 + "static-regular-grammar", 2172 + "thiserror 1.0.69", 2173 + ] 2174 + 2175 + [[package]] 2176 + name = "lazy_static" 2177 + version = "1.5.0" 2178 + source = "registry+https://github.com/rust-lang/crates.io-index" 2179 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2180 + dependencies = [ 2181 + "spin 0.9.8", 2182 + ] 2183 + 2184 + [[package]] 2185 + name = "lebe" 2186 + version = "0.5.3" 2187 + source = "registry+https://github.com/rust-lang/crates.io-index" 2188 + checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" 2189 + 2190 + [[package]] 2191 + name = "libc" 2192 + version = "0.2.177" 2193 + source = "registry+https://github.com/rust-lang/crates.io-index" 2194 + checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 2195 + 2196 + [[package]] 2197 + name = "libfuzzer-sys" 2198 + version = "0.4.10" 2199 + source = "registry+https://github.com/rust-lang/crates.io-index" 2200 + checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" 2201 + dependencies = [ 2202 + "arbitrary", 2203 + "cc", 2204 + ] 2205 + 2206 + [[package]] 2207 + name = "libm" 2208 + version = "0.2.15" 2209 + source = "registry+https://github.com/rust-lang/crates.io-index" 2210 + checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" 2211 + 2212 + [[package]] 2213 + name = "libredox" 2214 + version = "0.1.10" 2215 + source = "registry+https://github.com/rust-lang/crates.io-index" 2216 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 2217 + dependencies = [ 2218 + "bitflags", 2219 + "libc", 2220 + "redox_syscall", 2221 + ] 2222 + 2223 + [[package]] 2224 + name = "linked-hash-map" 2225 + version = "0.5.6" 2226 + source = "registry+https://github.com/rust-lang/crates.io-index" 2227 + checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 2228 + 2229 + [[package]] 2230 + name = "linux-raw-sys" 2231 + version = "0.11.0" 2232 + source = "registry+https://github.com/rust-lang/crates.io-index" 2233 + checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 2234 + 2235 + [[package]] 2236 + name = "litemap" 2237 + version = "0.8.0" 2238 + source = "registry+https://github.com/rust-lang/crates.io-index" 2239 + checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" 2240 + 2241 + [[package]] 2242 + name = "lock_api" 2243 + version = "0.4.14" 2244 + source = "registry+https://github.com/rust-lang/crates.io-index" 2245 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 2246 + dependencies = [ 2247 + "scopeguard", 2248 + ] 2249 + 2250 + [[package]] 2251 + name = "log" 2252 + version = "0.4.28" 2253 + source = "registry+https://github.com/rust-lang/crates.io-index" 2254 + checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 2255 + 2256 + [[package]] 2257 + name = "loom" 2258 + version = "0.7.2" 2259 + source = "registry+https://github.com/rust-lang/crates.io-index" 2260 + checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" 2261 + dependencies = [ 2262 + "cfg-if", 2263 + "generator", 2264 + "scoped-tls", 2265 + "tracing", 2266 + "tracing-subscriber", 2267 + ] 2268 + 2269 + [[package]] 2270 + name = "loop9" 2271 + version = "0.1.5" 2272 + source = "registry+https://github.com/rust-lang/crates.io-index" 2273 + checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" 2274 + dependencies = [ 2275 + "imgref", 2276 + ] 2277 + 2278 + [[package]] 2279 + name = "lru-cache" 2280 + version = "0.1.2" 2281 + source = "registry+https://github.com/rust-lang/crates.io-index" 2282 + checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" 2283 + dependencies = [ 2284 + "linked-hash-map", 2285 + ] 2286 + 2287 + [[package]] 2288 + name = "lru-slab" 2289 + version = "0.1.2" 2290 + source = "registry+https://github.com/rust-lang/crates.io-index" 2291 + checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 2292 + 2293 + [[package]] 2294 + name = "mac" 2295 + version = "0.1.1" 2296 + source = "registry+https://github.com/rust-lang/crates.io-index" 2297 + checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 2298 + 2299 + [[package]] 2300 + name = "malloc_buf" 2301 + version = "0.0.6" 2302 + source = "registry+https://github.com/rust-lang/crates.io-index" 2303 + checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" 2304 + dependencies = [ 2305 + "libc", 2306 + ] 2307 + 2308 + [[package]] 2309 + name = "markup5ever" 2310 + version = "0.12.1" 2311 + source = "registry+https://github.com/rust-lang/crates.io-index" 2312 + checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" 2313 + dependencies = [ 2314 + "log", 2315 + "phf", 2316 + "phf_codegen", 2317 + "string_cache", 2318 + "string_cache_codegen", 2319 + "tendril", 2320 + ] 2321 + 2322 + [[package]] 2323 + name = "markup5ever_rcdom" 2324 + version = "0.3.0" 2325 + source = "registry+https://github.com/rust-lang/crates.io-index" 2326 + checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" 2327 + dependencies = [ 2328 + "html5ever", 2329 + "markup5ever", 2330 + "tendril", 2331 + "xml5ever", 2332 + ] 2333 + 2334 + [[package]] 2335 + name = "match-lookup" 2336 + version = "0.1.1" 2337 + source = "registry+https://github.com/rust-lang/crates.io-index" 2338 + checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" 2339 + dependencies = [ 2340 + "proc-macro2", 2341 + "quote", 2342 + "syn 1.0.109", 2343 + ] 2344 + 2345 + [[package]] 2346 + name = "matchers" 2347 + version = "0.2.0" 2348 + source = "registry+https://github.com/rust-lang/crates.io-index" 2349 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 2350 + dependencies = [ 2351 + "regex-automata", 2352 + ] 2353 + 2354 + [[package]] 2355 + name = "maybe-rayon" 2356 + version = "0.1.1" 2357 + source = "registry+https://github.com/rust-lang/crates.io-index" 2358 + checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" 2359 + dependencies = [ 2360 + "cfg-if", 2361 + "rayon", 2362 + ] 2363 + 2364 + [[package]] 2365 + name = "memchr" 2366 + version = "2.7.6" 2367 + source = "registry+https://github.com/rust-lang/crates.io-index" 2368 + checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 2369 + 2370 + [[package]] 2371 + name = "miette" 2372 + version = "7.6.0" 2373 + source = "registry+https://github.com/rust-lang/crates.io-index" 2374 + checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" 2375 + dependencies = [ 2376 + "backtrace", 2377 + "backtrace-ext", 2378 + "cfg-if", 2379 + "miette-derive", 2380 + "owo-colors", 2381 + "supports-color", 2382 + "supports-hyperlinks", 2383 + "supports-unicode", 2384 + "terminal_size", 2385 + "textwrap", 2386 + "unicode-width 0.1.14", 2387 + ] 2388 + 2389 + [[package]] 2390 + name = "miette-derive" 2391 + version = "7.6.0" 2392 + source = "registry+https://github.com/rust-lang/crates.io-index" 2393 + checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" 2394 + dependencies = [ 2395 + "proc-macro2", 2396 + "quote", 2397 + "syn 2.0.108", 2398 + ] 2399 + 2400 + [[package]] 2401 + name = "mime" 2402 + version = "0.3.17" 2403 + source = "registry+https://github.com/rust-lang/crates.io-index" 2404 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 2405 + 2406 + [[package]] 2407 + name = "mime_guess" 2408 + version = "2.0.5" 2409 + source = "registry+https://github.com/rust-lang/crates.io-index" 2410 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 2411 + dependencies = [ 2412 + "mime", 2413 + "unicase", 2414 + ] 2415 + 2416 + [[package]] 2417 + name = "minimal-lexical" 2418 + version = "0.2.1" 2419 + source = "registry+https://github.com/rust-lang/crates.io-index" 2420 + checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 2421 + 2422 + [[package]] 2423 + name = "miniz_oxide" 2424 + version = "0.8.9" 2425 + source = "registry+https://github.com/rust-lang/crates.io-index" 2426 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 2427 + dependencies = [ 2428 + "adler2", 2429 + "simd-adler32", 2430 + ] 2431 + 2432 + [[package]] 2433 + name = "mio" 2434 + version = "1.1.0" 2435 + source = "registry+https://github.com/rust-lang/crates.io-index" 2436 + checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 2437 + dependencies = [ 2438 + "libc", 2439 + "wasi", 2440 + "windows-sys 0.61.2", 2441 + ] 2442 + 2443 + [[package]] 2444 + name = "mockito" 2445 + version = "1.7.0" 2446 + source = "registry+https://github.com/rust-lang/crates.io-index" 2447 + checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48" 2448 + dependencies = [ 2449 + "assert-json-diff", 2450 + "bytes", 2451 + "colored", 2452 + "futures-util", 2453 + "http", 2454 + "http-body", 2455 + "http-body-util", 2456 + "hyper", 2457 + "hyper-util", 2458 + "log", 2459 + "rand 0.9.2", 2460 + "regex", 2461 + "serde_json", 2462 + "serde_urlencoded", 2463 + "similar", 2464 + "tokio", 2465 + ] 2466 + 2467 + [[package]] 2468 + name = "moxcms" 2469 + version = "0.7.7" 2470 + source = "registry+https://github.com/rust-lang/crates.io-index" 2471 + checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" 2472 + dependencies = [ 2473 + "num-traits", 2474 + "pxfm", 2475 + ] 2476 + 2477 + [[package]] 2478 + name = "multibase" 2479 + version = "0.9.2" 2480 + source = "registry+https://github.com/rust-lang/crates.io-index" 2481 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 2482 + dependencies = [ 2483 + "base-x", 2484 + "base256emoji", 2485 + "data-encoding", 2486 + "data-encoding-macro", 2487 + ] 2488 + 2489 + [[package]] 2490 + name = "multihash" 2491 + version = "0.19.3" 2492 + source = "registry+https://github.com/rust-lang/crates.io-index" 2493 + checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" 2494 + dependencies = [ 2495 + "core2", 2496 + "serde", 2497 + "unsigned-varint", 2498 + ] 2499 + 2500 + [[package]] 2501 + name = "multipart" 2502 + version = "0.18.0" 2503 + source = "registry+https://github.com/rust-lang/crates.io-index" 2504 + checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" 2505 + dependencies = [ 2506 + "buf_redux", 2507 + "httparse", 2508 + "log", 2509 + "mime", 2510 + "mime_guess", 2511 + "quick-error 1.2.3", 2512 + "rand 0.8.5", 2513 + "safemem", 2514 + "tempfile", 2515 + "twoway", 2516 + ] 2517 + 2518 + [[package]] 2519 + name = "n0-future" 2520 + version = "0.1.3" 2521 + source = "registry+https://github.com/rust-lang/crates.io-index" 2522 + checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" 2523 + dependencies = [ 2524 + "cfg_aliases", 2525 + "derive_more", 2526 + "futures-buffered", 2527 + "futures-lite", 2528 + "futures-util", 2529 + "js-sys", 2530 + "pin-project", 2531 + "send_wrapper", 2532 + "tokio", 2533 + "tokio-util", 2534 + "wasm-bindgen", 2535 + "wasm-bindgen-futures", 2536 + "web-time", 2537 + ] 2538 + 2539 + [[package]] 2540 + name = "native-tls" 2541 + version = "0.2.14" 2542 + source = "registry+https://github.com/rust-lang/crates.io-index" 2543 + checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 2544 + dependencies = [ 2545 + "libc", 2546 + "log", 2547 + "openssl", 2548 + "openssl-probe", 2549 + "openssl-sys", 2550 + "schannel", 2551 + "security-framework 2.11.1", 2552 + "security-framework-sys", 2553 + "tempfile", 2554 + ] 2555 + 2556 + [[package]] 2557 + name = "ndk-context" 2558 + version = "0.1.1" 2559 + source = "registry+https://github.com/rust-lang/crates.io-index" 2560 + checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" 2561 + 2562 + [[package]] 2563 + name = "new_debug_unreachable" 2564 + version = "1.0.6" 2565 + source = "registry+https://github.com/rust-lang/crates.io-index" 2566 + checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 2567 + 2568 + [[package]] 2569 + name = "no-std-compat" 2570 + version = "0.4.1" 2571 + source = "registry+https://github.com/rust-lang/crates.io-index" 2572 + checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" 2573 + 2574 + [[package]] 2575 + name = "nom" 2576 + version = "7.1.3" 2577 + source = "registry+https://github.com/rust-lang/crates.io-index" 2578 + checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 2579 + dependencies = [ 2580 + "memchr", 2581 + "minimal-lexical", 2582 + ] 2583 + 2584 + [[package]] 2585 + name = "nom" 2586 + version = "8.0.0" 2587 + source = "registry+https://github.com/rust-lang/crates.io-index" 2588 + checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" 2589 + dependencies = [ 2590 + "memchr", 2591 + ] 2592 + 2593 + [[package]] 2594 + name = "nonzero_ext" 2595 + version = "0.3.0" 2596 + source = "registry+https://github.com/rust-lang/crates.io-index" 2597 + checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" 2598 + 2599 + [[package]] 2600 + name = "noop_proc_macro" 2601 + version = "0.3.0" 2602 + source = "registry+https://github.com/rust-lang/crates.io-index" 2603 + checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" 2604 + 2605 + [[package]] 2606 + name = "nu-ansi-term" 2607 + version = "0.50.3" 2608 + source = "registry+https://github.com/rust-lang/crates.io-index" 2609 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2610 + dependencies = [ 2611 + "windows-sys 0.61.2", 2612 + ] 2613 + 2614 + [[package]] 2615 + name = "num-bigint" 2616 + version = "0.4.6" 2617 + source = "registry+https://github.com/rust-lang/crates.io-index" 2618 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 2619 + dependencies = [ 2620 + "num-integer", 2621 + "num-traits", 2622 + ] 2623 + 2624 + [[package]] 2625 + name = "num-bigint-dig" 2626 + version = "0.8.4" 2627 + source = "registry+https://github.com/rust-lang/crates.io-index" 2628 + checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" 2629 + dependencies = [ 2630 + "byteorder", 2631 + "lazy_static", 2632 + "libm", 2633 + "num-integer", 2634 + "num-iter", 2635 + "num-traits", 2636 + "rand 0.8.5", 2637 + "smallvec", 2638 + "zeroize", 2639 + ] 2640 + 2641 + [[package]] 2642 + name = "num-complex" 2643 + version = "0.4.6" 2644 + source = "registry+https://github.com/rust-lang/crates.io-index" 2645 + checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" 2646 + dependencies = [ 2647 + "num-traits", 2648 + ] 2649 + 2650 + [[package]] 2651 + name = "num-conv" 2652 + version = "0.1.0" 2653 + source = "registry+https://github.com/rust-lang/crates.io-index" 2654 + checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 2655 + 2656 + [[package]] 2657 + name = "num-derive" 2658 + version = "0.4.2" 2659 + source = "registry+https://github.com/rust-lang/crates.io-index" 2660 + checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" 2661 + dependencies = [ 2662 + "proc-macro2", 2663 + "quote", 2664 + "syn 2.0.108", 2665 + ] 2666 + 2667 + [[package]] 2668 + name = "num-integer" 2669 + version = "0.1.46" 2670 + source = "registry+https://github.com/rust-lang/crates.io-index" 2671 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 2672 + dependencies = [ 2673 + "num-traits", 2674 + ] 2675 + 2676 + [[package]] 2677 + name = "num-iter" 2678 + version = "0.1.45" 2679 + source = "registry+https://github.com/rust-lang/crates.io-index" 2680 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 2681 + dependencies = [ 2682 + "autocfg", 2683 + "num-integer", 2684 + "num-traits", 2685 + ] 2686 + 2687 + [[package]] 2688 + name = "num-rational" 2689 + version = "0.4.2" 2690 + source = "registry+https://github.com/rust-lang/crates.io-index" 2691 + checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" 2692 + dependencies = [ 2693 + "num-bigint", 2694 + "num-integer", 2695 + "num-traits", 2696 + ] 2697 + 2698 + [[package]] 2699 + name = "num-traits" 2700 + version = "0.2.19" 2701 + source = "registry+https://github.com/rust-lang/crates.io-index" 2702 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 2703 + dependencies = [ 2704 + "autocfg", 2705 + "libm", 2706 + ] 2707 + 2708 + [[package]] 2709 + name = "num_cpus" 2710 + version = "1.17.0" 2711 + source = "registry+https://github.com/rust-lang/crates.io-index" 2712 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 2713 + dependencies = [ 2714 + "hermit-abi", 2715 + "libc", 2716 + ] 2717 + 2718 + [[package]] 2719 + name = "num_threads" 2720 + version = "0.1.7" 2721 + source = "registry+https://github.com/rust-lang/crates.io-index" 2722 + checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 2723 + dependencies = [ 2724 + "libc", 2725 + ] 2726 + 2727 + [[package]] 2728 + name = "objc" 2729 + version = "0.2.7" 2730 + source = "registry+https://github.com/rust-lang/crates.io-index" 2731 + checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" 2732 + dependencies = [ 2733 + "malloc_buf", 2734 + ] 2735 + 2736 + [[package]] 2737 + name = "object" 2738 + version = "0.37.3" 2739 + source = "registry+https://github.com/rust-lang/crates.io-index" 2740 + checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" 2741 + dependencies = [ 2742 + "memchr", 2743 + ] 2744 + 2745 + [[package]] 2746 + name = "once_cell" 2747 + version = "1.21.3" 2748 + source = "registry+https://github.com/rust-lang/crates.io-index" 2749 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 2750 + 2751 + [[package]] 2752 + name = "openssl" 2753 + version = "0.10.74" 2754 + source = "registry+https://github.com/rust-lang/crates.io-index" 2755 + checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" 2756 + dependencies = [ 2757 + "bitflags", 2758 + "cfg-if", 2759 + "foreign-types", 2760 + "libc", 2761 + "once_cell", 2762 + "openssl-macros", 2763 + "openssl-sys", 2764 + ] 2765 + 2766 + [[package]] 2767 + name = "openssl-macros" 2768 + version = "0.1.1" 2769 + source = "registry+https://github.com/rust-lang/crates.io-index" 2770 + checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 2771 + dependencies = [ 2772 + "proc-macro2", 2773 + "quote", 2774 + "syn 2.0.108", 2775 + ] 2776 + 2777 + [[package]] 2778 + name = "openssl-probe" 2779 + version = "0.1.6" 2780 + source = "registry+https://github.com/rust-lang/crates.io-index" 2781 + checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 2782 + 2783 + [[package]] 2784 + name = "openssl-sys" 2785 + version = "0.9.110" 2786 + source = "registry+https://github.com/rust-lang/crates.io-index" 2787 + checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" 2788 + dependencies = [ 2789 + "cc", 2790 + "libc", 2791 + "pkg-config", 2792 + "vcpkg", 2793 + ] 2794 + 2795 + [[package]] 2796 + name = "ouroboros" 2797 + version = "0.18.5" 2798 + source = "registry+https://github.com/rust-lang/crates.io-index" 2799 + checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" 2800 + dependencies = [ 2801 + "aliasable", 2802 + "ouroboros_macro", 2803 + "static_assertions", 2804 + ] 2805 + 2806 + [[package]] 2807 + name = "ouroboros_macro" 2808 + version = "0.18.5" 2809 + source = "registry+https://github.com/rust-lang/crates.io-index" 2810 + checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" 2811 + dependencies = [ 2812 + "heck 0.4.1", 2813 + "proc-macro2", 2814 + "proc-macro2-diagnostics", 2815 + "quote", 2816 + "syn 2.0.108", 2817 + ] 2818 + 2819 + [[package]] 2820 + name = "owo-colors" 2821 + version = "4.2.3" 2822 + source = "registry+https://github.com/rust-lang/crates.io-index" 2823 + checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" 2824 + 2825 + [[package]] 2826 + name = "p256" 2827 + version = "0.13.2" 2828 + source = "registry+https://github.com/rust-lang/crates.io-index" 2829 + checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 2830 + dependencies = [ 2831 + "ecdsa", 2832 + "elliptic-curve", 2833 + "primeorder", 2834 + "sha2", 2835 + ] 2836 + 2837 + [[package]] 2838 + name = "p384" 2839 + version = "0.13.1" 2840 + source = "registry+https://github.com/rust-lang/crates.io-index" 2841 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 2842 + dependencies = [ 2843 + "elliptic-curve", 2844 + "primeorder", 2845 + ] 2846 + 2847 + [[package]] 2848 + name = "parking" 2849 + version = "2.2.1" 2850 + source = "registry+https://github.com/rust-lang/crates.io-index" 2851 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 2852 + 2853 + [[package]] 2854 + name = "parking_lot" 2855 + version = "0.12.5" 2856 + source = "registry+https://github.com/rust-lang/crates.io-index" 2857 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 2858 + dependencies = [ 2859 + "lock_api", 2860 + "parking_lot_core", 2861 + ] 2862 + 2863 + [[package]] 2864 + name = "parking_lot_core" 2865 + version = "0.9.12" 2866 + source = "registry+https://github.com/rust-lang/crates.io-index" 2867 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 2868 + dependencies = [ 2869 + "cfg-if", 2870 + "libc", 2871 + "redox_syscall", 2872 + "smallvec", 2873 + "windows-link 0.2.1", 2874 + ] 2875 + 2876 + [[package]] 2877 + name = "paste" 2878 + version = "1.0.15" 2879 + source = "registry+https://github.com/rust-lang/crates.io-index" 2880 + checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 2881 + 2882 + [[package]] 2883 + name = "pem-rfc7468" 2884 + version = "0.7.0" 2885 + source = "registry+https://github.com/rust-lang/crates.io-index" 2886 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 2887 + dependencies = [ 2888 + "base64ct", 2889 + ] 2890 + 2891 + [[package]] 2892 + name = "percent-encoding" 2893 + version = "2.3.2" 2894 + source = "registry+https://github.com/rust-lang/crates.io-index" 2895 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 2896 + 2897 + [[package]] 2898 + name = "phf" 2899 + version = "0.11.3" 2900 + source = "registry+https://github.com/rust-lang/crates.io-index" 2901 + checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" 2902 + dependencies = [ 2903 + "phf_shared", 2904 + ] 2905 + 2906 + [[package]] 2907 + name = "phf_codegen" 2908 + version = "0.11.3" 2909 + source = "registry+https://github.com/rust-lang/crates.io-index" 2910 + checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" 2911 + dependencies = [ 2912 + "phf_generator", 2913 + "phf_shared", 2914 + ] 2915 + 2916 + [[package]] 2917 + name = "phf_generator" 2918 + version = "0.11.3" 2919 + source = "registry+https://github.com/rust-lang/crates.io-index" 2920 + checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 2921 + dependencies = [ 2922 + "phf_shared", 2923 + "rand 0.8.5", 2924 + ] 2925 + 2926 + [[package]] 2927 + name = "phf_shared" 2928 + version = "0.11.3" 2929 + source = "registry+https://github.com/rust-lang/crates.io-index" 2930 + checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 2931 + dependencies = [ 2932 + "siphasher", 2933 + ] 2934 + 2935 + [[package]] 2936 + name = "pin-project" 2937 + version = "1.1.10" 2938 + source = "registry+https://github.com/rust-lang/crates.io-index" 2939 + checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" 2940 + dependencies = [ 2941 + "pin-project-internal", 2942 + ] 2943 + 2944 + [[package]] 2945 + name = "pin-project-internal" 2946 + version = "1.1.10" 2947 + source = "registry+https://github.com/rust-lang/crates.io-index" 2948 + checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" 2949 + dependencies = [ 2950 + "proc-macro2", 2951 + "quote", 2952 + "syn 2.0.108", 2953 + ] 2954 + 2955 + [[package]] 2956 + name = "pin-project-lite" 2957 + version = "0.2.16" 2958 + source = "registry+https://github.com/rust-lang/crates.io-index" 2959 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 2960 + 2961 + [[package]] 2962 + name = "pin-utils" 2963 + version = "0.1.0" 2964 + source = "registry+https://github.com/rust-lang/crates.io-index" 2965 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 2966 + 2967 + [[package]] 2968 + name = "pkcs1" 2969 + version = "0.7.5" 2970 + source = "registry+https://github.com/rust-lang/crates.io-index" 2971 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 2972 + dependencies = [ 2973 + "der", 2974 + "pkcs8", 2975 + "spki", 2976 + ] 2977 + 2978 + [[package]] 2979 + name = "pkcs8" 2980 + version = "0.10.2" 2981 + source = "registry+https://github.com/rust-lang/crates.io-index" 2982 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 2983 + dependencies = [ 2984 + "der", 2985 + "spki", 2986 + ] 2987 + 2988 + [[package]] 2989 + name = "pkg-config" 2990 + version = "0.3.32" 2991 + source = "registry+https://github.com/rust-lang/crates.io-index" 2992 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 2993 + 2994 + [[package]] 2995 + name = "png" 2996 + version = "0.18.0" 2997 + source = "registry+https://github.com/rust-lang/crates.io-index" 2998 + checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" 2999 + dependencies = [ 3000 + "bitflags", 3001 + "crc32fast", 3002 + "fdeflate", 3003 + "flate2", 3004 + "miniz_oxide", 3005 + ] 3006 + 3007 + [[package]] 3008 + name = "portable-atomic" 3009 + version = "1.11.1" 3010 + source = "registry+https://github.com/rust-lang/crates.io-index" 3011 + checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 3012 + 3013 + [[package]] 3014 + name = "potential_utf" 3015 + version = "0.1.3" 3016 + source = "registry+https://github.com/rust-lang/crates.io-index" 3017 + checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" 3018 + dependencies = [ 3019 + "zerovec", 3020 + ] 3021 + 3022 + [[package]] 3023 + name = "powerfmt" 3024 + version = "0.2.0" 3025 + source = "registry+https://github.com/rust-lang/crates.io-index" 3026 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 3027 + 3028 + [[package]] 3029 + name = "ppv-lite86" 3030 + version = "0.2.21" 3031 + source = "registry+https://github.com/rust-lang/crates.io-index" 3032 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 3033 + dependencies = [ 3034 + "zerocopy", 3035 + ] 3036 + 3037 + [[package]] 3038 + name = "precomputed-hash" 3039 + version = "0.1.1" 3040 + source = "registry+https://github.com/rust-lang/crates.io-index" 3041 + checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3042 + 3043 + [[package]] 3044 + name = "prettyplease" 3045 + version = "0.2.37" 3046 + source = "registry+https://github.com/rust-lang/crates.io-index" 3047 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 3048 + dependencies = [ 3049 + "proc-macro2", 3050 + "syn 2.0.108", 3051 + ] 3052 + 3053 + [[package]] 3054 + name = "primal-check" 3055 + version = "0.3.4" 3056 + source = "registry+https://github.com/rust-lang/crates.io-index" 3057 + checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" 3058 + dependencies = [ 3059 + "num-integer", 3060 + ] 3061 + 3062 + [[package]] 3063 + name = "primeorder" 3064 + version = "0.13.6" 3065 + source = "registry+https://github.com/rust-lang/crates.io-index" 3066 + checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" 3067 + dependencies = [ 3068 + "elliptic-curve", 3069 + ] 3070 + 3071 + [[package]] 3072 + name = "proc-macro-error" 3073 + version = "1.0.4" 3074 + source = "registry+https://github.com/rust-lang/crates.io-index" 3075 + checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 3076 + dependencies = [ 3077 + "proc-macro-error-attr", 3078 + "proc-macro2", 3079 + "quote", 3080 + "syn 1.0.109", 3081 + "version_check", 3082 + ] 3083 + 3084 + [[package]] 3085 + name = "proc-macro-error-attr" 3086 + version = "1.0.4" 3087 + source = "registry+https://github.com/rust-lang/crates.io-index" 3088 + checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 3089 + dependencies = [ 3090 + "proc-macro2", 3091 + "quote", 3092 + "version_check", 3093 + ] 3094 + 3095 + [[package]] 3096 + name = "proc-macro2" 3097 + version = "1.0.103" 3098 + source = "registry+https://github.com/rust-lang/crates.io-index" 3099 + checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 3100 + dependencies = [ 3101 + "unicode-ident", 3102 + ] 3103 + 3104 + [[package]] 3105 + name = "proc-macro2-diagnostics" 3106 + version = "0.10.1" 3107 + source = "registry+https://github.com/rust-lang/crates.io-index" 3108 + checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 3109 + dependencies = [ 3110 + "proc-macro2", 3111 + "quote", 3112 + "syn 2.0.108", 3113 + "version_check", 3114 + "yansi", 3115 + ] 3116 + 3117 + [[package]] 3118 + name = "profiling" 3119 + version = "1.0.17" 3120 + source = "registry+https://github.com/rust-lang/crates.io-index" 3121 + checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" 3122 + dependencies = [ 3123 + "profiling-procmacros", 3124 + ] 3125 + 3126 + [[package]] 3127 + name = "profiling-procmacros" 3128 + version = "1.0.17" 3129 + source = "registry+https://github.com/rust-lang/crates.io-index" 3130 + checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" 3131 + dependencies = [ 3132 + "quote", 3133 + "syn 2.0.108", 3134 + ] 3135 + 3136 + [[package]] 3137 + name = "pxfm" 3138 + version = "0.1.25" 3139 + source = "registry+https://github.com/rust-lang/crates.io-index" 3140 + checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" 3141 + dependencies = [ 3142 + "num-traits", 3143 + ] 3144 + 3145 + [[package]] 3146 + name = "qoi" 3147 + version = "0.4.1" 3148 + source = "registry+https://github.com/rust-lang/crates.io-index" 3149 + checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" 3150 + dependencies = [ 3151 + "bytemuck", 3152 + ] 3153 + 3154 + [[package]] 3155 + name = "quanta" 3156 + version = "0.12.6" 3157 + source = "registry+https://github.com/rust-lang/crates.io-index" 3158 + checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" 3159 + dependencies = [ 3160 + "crossbeam-utils", 3161 + "libc", 3162 + "once_cell", 3163 + "raw-cpuid", 3164 + "wasi", 3165 + "web-sys", 3166 + "winapi", 3167 + ] 3168 + 3169 + [[package]] 3170 + name = "quick-error" 3171 + version = "1.2.3" 3172 + source = "registry+https://github.com/rust-lang/crates.io-index" 3173 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 3174 + 3175 + [[package]] 3176 + name = "quick-error" 3177 + version = "2.0.1" 3178 + source = "registry+https://github.com/rust-lang/crates.io-index" 3179 + checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 3180 + 3181 + [[package]] 3182 + name = "quinn" 3183 + version = "0.11.9" 3184 + source = "registry+https://github.com/rust-lang/crates.io-index" 3185 + checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" 3186 + dependencies = [ 3187 + "bytes", 3188 + "cfg_aliases", 3189 + "pin-project-lite", 3190 + "quinn-proto", 3191 + "quinn-udp", 3192 + "rustc-hash", 3193 + "rustls", 3194 + "socket2 0.6.1", 3195 + "thiserror 2.0.17", 3196 + "tokio", 3197 + "tracing", 3198 + "web-time", 3199 + ] 3200 + 3201 + [[package]] 3202 + name = "quinn-proto" 3203 + version = "0.11.13" 3204 + source = "registry+https://github.com/rust-lang/crates.io-index" 3205 + checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" 3206 + dependencies = [ 3207 + "bytes", 3208 + "getrandom 0.3.4", 3209 + "lru-slab", 3210 + "rand 0.9.2", 3211 + "ring", 3212 + "rustc-hash", 3213 + "rustls", 3214 + "rustls-pki-types", 3215 + "slab", 3216 + "thiserror 2.0.17", 3217 + "tinyvec", 3218 + "tracing", 3219 + "web-time", 3220 + ] 3221 + 3222 + [[package]] 3223 + name = "quinn-udp" 3224 + version = "0.5.14" 3225 + source = "registry+https://github.com/rust-lang/crates.io-index" 3226 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 3227 + dependencies = [ 3228 + "cfg_aliases", 3229 + "libc", 3230 + "once_cell", 3231 + "socket2 0.6.1", 3232 + "tracing", 3233 + "windows-sys 0.60.2", 3234 + ] 3235 + 3236 + [[package]] 3237 + name = "quote" 3238 + version = "1.0.41" 3239 + source = "registry+https://github.com/rust-lang/crates.io-index" 3240 + checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 3241 + dependencies = [ 3242 + "proc-macro2", 3243 + ] 3244 + 3245 + [[package]] 3246 + name = "r-efi" 3247 + version = "5.3.0" 3248 + source = "registry+https://github.com/rust-lang/crates.io-index" 3249 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 3250 + 3251 + [[package]] 3252 + name = "rand" 3253 + version = "0.8.5" 3254 + source = "registry+https://github.com/rust-lang/crates.io-index" 3255 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 3256 + dependencies = [ 3257 + "libc", 3258 + "rand_chacha 0.3.1", 3259 + "rand_core 0.6.4", 3260 + ] 3261 + 3262 + [[package]] 3263 + name = "rand" 3264 + version = "0.9.2" 3265 + source = "registry+https://github.com/rust-lang/crates.io-index" 3266 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 3267 + dependencies = [ 3268 + "rand_chacha 0.9.0", 3269 + "rand_core 0.9.3", 3270 + ] 3271 + 3272 + [[package]] 3273 + name = "rand_chacha" 3274 + version = "0.3.1" 3275 + source = "registry+https://github.com/rust-lang/crates.io-index" 3276 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 3277 + dependencies = [ 3278 + "ppv-lite86", 3279 + "rand_core 0.6.4", 3280 + ] 3281 + 3282 + [[package]] 3283 + name = "rand_chacha" 3284 + version = "0.9.0" 3285 + source = "registry+https://github.com/rust-lang/crates.io-index" 3286 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 3287 + dependencies = [ 3288 + "ppv-lite86", 3289 + "rand_core 0.9.3", 3290 + ] 3291 + 3292 + [[package]] 3293 + name = "rand_core" 3294 + version = "0.6.4" 3295 + source = "registry+https://github.com/rust-lang/crates.io-index" 3296 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 3297 + dependencies = [ 3298 + "getrandom 0.2.16", 3299 + ] 3300 + 3301 + [[package]] 3302 + name = "rand_core" 3303 + version = "0.9.3" 3304 + source = "registry+https://github.com/rust-lang/crates.io-index" 3305 + checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 3306 + dependencies = [ 3307 + "getrandom 0.3.4", 3308 + ] 3309 + 3310 + [[package]] 3311 + name = "range-traits" 3312 + version = "0.3.2" 3313 + source = "registry+https://github.com/rust-lang/crates.io-index" 3314 + checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab" 3315 + 3316 + [[package]] 3317 + name = "rav1e" 3318 + version = "0.7.1" 3319 + source = "registry+https://github.com/rust-lang/crates.io-index" 3320 + checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" 3321 + dependencies = [ 3322 + "arbitrary", 3323 + "arg_enum_proc_macro", 3324 + "arrayvec", 3325 + "av1-grain", 3326 + "bitstream-io", 3327 + "built", 3328 + "cfg-if", 3329 + "interpolate_name", 3330 + "itertools 0.12.1", 3331 + "libc", 3332 + "libfuzzer-sys", 3333 + "log", 3334 + "maybe-rayon", 3335 + "new_debug_unreachable", 3336 + "noop_proc_macro", 3337 + "num-derive", 3338 + "num-traits", 3339 + "once_cell", 3340 + "paste", 3341 + "profiling", 3342 + "rand 0.8.5", 3343 + "rand_chacha 0.3.1", 3344 + "simd_helpers", 3345 + "system-deps", 3346 + "thiserror 1.0.69", 3347 + "v_frame", 3348 + "wasm-bindgen", 3349 + ] 3350 + 3351 + [[package]] 3352 + name = "ravif" 3353 + version = "0.11.20" 3354 + source = "registry+https://github.com/rust-lang/crates.io-index" 3355 + checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" 3356 + dependencies = [ 3357 + "avif-serialize", 3358 + "imgref", 3359 + "loop9", 3360 + "quick-error 2.0.1", 3361 + "rav1e", 3362 + "rayon", 3363 + "rgb", 3364 + ] 3365 + 3366 + [[package]] 3367 + name = "raw-cpuid" 3368 + version = "11.6.0" 3369 + source = "registry+https://github.com/rust-lang/crates.io-index" 3370 + checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" 3371 + dependencies = [ 3372 + "bitflags", 3373 + ] 3374 + 3375 + [[package]] 3376 + name = "raw-window-handle" 3377 + version = "0.5.2" 3378 + source = "registry+https://github.com/rust-lang/crates.io-index" 3379 + checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" 3380 + 3381 + [[package]] 3382 + name = "rayon" 3383 + version = "1.11.0" 3384 + source = "registry+https://github.com/rust-lang/crates.io-index" 3385 + checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" 3386 + dependencies = [ 3387 + "either", 3388 + "rayon-core", 3389 + ] 3390 + 3391 + [[package]] 3392 + name = "rayon-core" 3393 + version = "1.13.0" 3394 + source = "registry+https://github.com/rust-lang/crates.io-index" 3395 + checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" 3396 + dependencies = [ 3397 + "crossbeam-deque", 3398 + "crossbeam-utils", 3399 + ] 3400 + 3401 + [[package]] 3402 + name = "redis" 3403 + version = "0.27.6" 3404 + source = "registry+https://github.com/rust-lang/crates.io-index" 3405 + checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" 3406 + dependencies = [ 3407 + "arc-swap", 3408 + "async-trait", 3409 + "backon", 3410 + "bytes", 3411 + "combine", 3412 + "futures", 3413 + "futures-util", 3414 + "itertools 0.13.0", 3415 + "itoa", 3416 + "num-bigint", 3417 + "percent-encoding", 3418 + "pin-project-lite", 3419 + "ryu", 3420 + "sha1_smol", 3421 + "socket2 0.5.10", 3422 + "tokio", 3423 + "tokio-util", 3424 + "url", 3425 + ] 3426 + 3427 + [[package]] 3428 + name = "redox_syscall" 3429 + version = "0.5.18" 3430 + source = "registry+https://github.com/rust-lang/crates.io-index" 3431 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 3432 + dependencies = [ 3433 + "bitflags", 3434 + ] 3435 + 3436 + [[package]] 3437 + name = "regex" 3438 + version = "1.12.2" 3439 + source = "registry+https://github.com/rust-lang/crates.io-index" 3440 + checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 3441 + dependencies = [ 3442 + "aho-corasick", 3443 + "memchr", 3444 + "regex-automata", 3445 + "regex-syntax", 3446 + ] 3447 + 3448 + [[package]] 3449 + name = "regex-automata" 3450 + version = "0.4.13" 3451 + source = "registry+https://github.com/rust-lang/crates.io-index" 3452 + checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 3453 + dependencies = [ 3454 + "aho-corasick", 3455 + "memchr", 3456 + "regex-syntax", 3457 + ] 3458 + 3459 + [[package]] 3460 + name = "regex-syntax" 3461 + version = "0.8.8" 3462 + source = "registry+https://github.com/rust-lang/crates.io-index" 3463 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 3464 + 3465 + [[package]] 3466 + name = "reqwest" 3467 + version = "0.12.24" 3468 + source = "registry+https://github.com/rust-lang/crates.io-index" 3469 + checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" 3470 + dependencies = [ 3471 + "async-compression", 3472 + "base64 0.22.1", 3473 + "bytes", 3474 + "encoding_rs", 3475 + "futures-core", 3476 + "futures-util", 3477 + "h2", 3478 + "http", 3479 + "http-body", 3480 + "http-body-util", 3481 + "hyper", 3482 + "hyper-rustls", 3483 + "hyper-tls", 3484 + "hyper-util", 3485 + "js-sys", 3486 + "log", 3487 + "mime", 3488 + "native-tls", 3489 + "percent-encoding", 3490 + "pin-project-lite", 3491 + "quinn", 3492 + "rustls", 3493 + "rustls-pki-types", 3494 + "serde", 3495 + "serde_json", 3496 + "serde_urlencoded", 3497 + "sync_wrapper", 3498 + "tokio", 3499 + "tokio-native-tls", 3500 + "tokio-rustls", 3501 + "tokio-util", 3502 + "tower", 3503 + "tower-http", 3504 + "tower-service", 3505 + "url", 3506 + "wasm-bindgen", 3507 + "wasm-bindgen-futures", 3508 + "wasm-streams", 3509 + "web-sys", 3510 + "webpki-roots", 3511 + ] 3512 + 3513 + [[package]] 3514 + name = "resolv-conf" 3515 + version = "0.7.5" 3516 + source = "registry+https://github.com/rust-lang/crates.io-index" 3517 + checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 3518 + 3519 + [[package]] 3520 + name = "rfc6979" 3521 + version = "0.4.0" 3522 + source = "registry+https://github.com/rust-lang/crates.io-index" 3523 + checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 3524 + dependencies = [ 3525 + "hmac", 3526 + "subtle", 3527 + ] 3528 + 3529 + [[package]] 3530 + name = "rgb" 3531 + version = "0.8.52" 3532 + source = "registry+https://github.com/rust-lang/crates.io-index" 3533 + checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" 3534 + 3535 + [[package]] 3536 + name = "ring" 3537 + version = "0.17.14" 3538 + source = "registry+https://github.com/rust-lang/crates.io-index" 3539 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 3540 + dependencies = [ 3541 + "cc", 3542 + "cfg-if", 3543 + "getrandom 0.2.16", 3544 + "libc", 3545 + "untrusted", 3546 + "windows-sys 0.52.0", 3547 + ] 3548 + 3549 + [[package]] 3550 + name = "rouille" 3551 + version = "3.6.2" 3552 + source = "registry+https://github.com/rust-lang/crates.io-index" 3553 + checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" 3554 + dependencies = [ 3555 + "base64 0.13.1", 3556 + "brotli", 3557 + "chrono", 3558 + "deflate", 3559 + "filetime", 3560 + "multipart", 3561 + "percent-encoding", 3562 + "rand 0.8.5", 3563 + "serde", 3564 + "serde_derive", 3565 + "serde_json", 3566 + "sha1_smol", 3567 + "threadpool", 3568 + "time", 3569 + "tiny_http", 3570 + "url", 3571 + ] 3572 + 3573 + [[package]] 3574 + name = "rsa" 3575 + version = "0.9.8" 3576 + source = "registry+https://github.com/rust-lang/crates.io-index" 3577 + checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" 3578 + dependencies = [ 3579 + "const-oid", 3580 + "digest", 3581 + "num-bigint-dig", 3582 + "num-integer", 3583 + "num-traits", 3584 + "pkcs1", 3585 + "pkcs8", 3586 + "rand_core 0.6.4", 3587 + "signature", 3588 + "spki", 3589 + "subtle", 3590 + "zeroize", 3591 + ] 3592 + 3593 + [[package]] 3594 + name = "rustc-demangle" 3595 + version = "0.1.26" 3596 + source = "registry+https://github.com/rust-lang/crates.io-index" 3597 + checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" 3598 + 3599 + [[package]] 3600 + name = "rustc-hash" 3601 + version = "2.1.1" 3602 + source = "registry+https://github.com/rust-lang/crates.io-index" 3603 + checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 3604 + 3605 + [[package]] 3606 + name = "rustdct" 3607 + version = "0.7.1" 3608 + source = "registry+https://github.com/rust-lang/crates.io-index" 3609 + checksum = "8b61555105d6a9bf98797c063c362a1d24ed8ab0431655e38f1cf51e52089551" 3610 + dependencies = [ 3611 + "rustfft", 3612 + ] 3613 + 3614 + [[package]] 3615 + name = "rustfft" 3616 + version = "6.4.1" 3617 + source = "registry+https://github.com/rust-lang/crates.io-index" 3618 + checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" 3619 + dependencies = [ 3620 + "num-complex", 3621 + "num-integer", 3622 + "num-traits", 3623 + "primal-check", 3624 + "strength_reduce", 3625 + "transpose", 3626 + ] 3627 + 3628 + [[package]] 3629 + name = "rustix" 3630 + version = "1.1.2" 3631 + source = "registry+https://github.com/rust-lang/crates.io-index" 3632 + checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 3633 + dependencies = [ 3634 + "bitflags", 3635 + "errno", 3636 + "libc", 3637 + "linux-raw-sys", 3638 + "windows-sys 0.61.2", 3639 + ] 3640 + 3641 + [[package]] 3642 + name = "rustls" 3643 + version = "0.23.34" 3644 + source = "registry+https://github.com/rust-lang/crates.io-index" 3645 + checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" 3646 + dependencies = [ 3647 + "once_cell", 3648 + "ring", 3649 + "rustls-pki-types", 3650 + "rustls-webpki", 3651 + "subtle", 3652 + "zeroize", 3653 + ] 3654 + 3655 + [[package]] 3656 + name = "rustls-native-certs" 3657 + version = "0.8.2" 3658 + source = "registry+https://github.com/rust-lang/crates.io-index" 3659 + checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" 3660 + dependencies = [ 3661 + "openssl-probe", 3662 + "rustls-pki-types", 3663 + "schannel", 3664 + "security-framework 3.5.1", 3665 + ] 3666 + 3667 + [[package]] 3668 + name = "rustls-pki-types" 3669 + version = "1.12.0" 3670 + source = "registry+https://github.com/rust-lang/crates.io-index" 3671 + checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 3672 + dependencies = [ 3673 + "web-time", 3674 + "zeroize", 3675 + ] 3676 + 3677 + [[package]] 3678 + name = "rustls-webpki" 3679 + version = "0.103.7" 3680 + source = "registry+https://github.com/rust-lang/crates.io-index" 3681 + checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" 3682 + dependencies = [ 3683 + "ring", 3684 + "rustls-pki-types", 3685 + "untrusted", 3686 + ] 3687 + 3688 + [[package]] 3689 + name = "rustversion" 3690 + version = "1.0.22" 3691 + source = "registry+https://github.com/rust-lang/crates.io-index" 3692 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 3693 + 3694 + [[package]] 3695 + name = "ryu" 3696 + version = "1.0.20" 3697 + source = "registry+https://github.com/rust-lang/crates.io-index" 3698 + checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 3699 + 3700 + [[package]] 3701 + name = "safemem" 3702 + version = "0.3.3" 3703 + source = "registry+https://github.com/rust-lang/crates.io-index" 3704 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 3705 + 3706 + [[package]] 3707 + name = "same-file" 3708 + version = "1.0.6" 3709 + source = "registry+https://github.com/rust-lang/crates.io-index" 3710 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 3711 + dependencies = [ 3712 + "winapi-util", 3713 + ] 3714 + 3715 + [[package]] 3716 + name = "schannel" 3717 + version = "0.1.28" 3718 + source = "registry+https://github.com/rust-lang/crates.io-index" 3719 + checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 3720 + dependencies = [ 3721 + "windows-sys 0.61.2", 3722 + ] 3723 + 3724 + [[package]] 3725 + name = "scoped-tls" 3726 + version = "1.0.1" 3727 + source = "registry+https://github.com/rust-lang/crates.io-index" 3728 + checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 3729 + 3730 + [[package]] 3731 + name = "scopeguard" 3732 + version = "1.2.0" 3733 + source = "registry+https://github.com/rust-lang/crates.io-index" 3734 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 3735 + 3736 + [[package]] 3737 + name = "sec1" 3738 + version = "0.7.3" 3739 + source = "registry+https://github.com/rust-lang/crates.io-index" 3740 + checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" 3741 + dependencies = [ 3742 + "base16ct", 3743 + "der", 3744 + "generic-array", 3745 + "pkcs8", 3746 + "subtle", 3747 + "zeroize", 3748 + ] 3749 + 3750 + [[package]] 3751 + name = "security-framework" 3752 + version = "2.11.1" 3753 + source = "registry+https://github.com/rust-lang/crates.io-index" 3754 + checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 3755 + dependencies = [ 3756 + "bitflags", 3757 + "core-foundation 0.9.4", 3758 + "core-foundation-sys", 3759 + "libc", 3760 + "security-framework-sys", 3761 + ] 3762 + 3763 + [[package]] 3764 + name = "security-framework" 3765 + version = "3.5.1" 3766 + source = "registry+https://github.com/rust-lang/crates.io-index" 3767 + checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" 3768 + dependencies = [ 3769 + "bitflags", 3770 + "core-foundation 0.10.1", 3771 + "core-foundation-sys", 3772 + "libc", 3773 + "security-framework-sys", 3774 + ] 3775 + 3776 + [[package]] 3777 + name = "security-framework-sys" 3778 + version = "2.15.0" 3779 + source = "registry+https://github.com/rust-lang/crates.io-index" 3780 + checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 3781 + dependencies = [ 3782 + "core-foundation-sys", 3783 + "libc", 3784 + ] 3785 + 3786 + [[package]] 3787 + name = "send_wrapper" 3788 + version = "0.6.0" 3789 + source = "registry+https://github.com/rust-lang/crates.io-index" 3790 + checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" 3791 + 3792 + [[package]] 3793 + name = "serde" 3794 + version = "1.0.228" 3795 + source = "registry+https://github.com/rust-lang/crates.io-index" 3796 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 3797 + dependencies = [ 3798 + "serde_core", 3799 + "serde_derive", 3800 + ] 3801 + 3802 + [[package]] 3803 + name = "serde_bytes" 3804 + version = "0.11.19" 3805 + source = "registry+https://github.com/rust-lang/crates.io-index" 3806 + checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 3807 + dependencies = [ 3808 + "serde", 3809 + "serde_core", 3810 + ] 3811 + 3812 + [[package]] 3813 + name = "serde_core" 3814 + version = "1.0.228" 3815 + source = "registry+https://github.com/rust-lang/crates.io-index" 3816 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 3817 + dependencies = [ 3818 + "serde_derive", 3819 + ] 3820 + 3821 + [[package]] 3822 + name = "serde_derive" 3823 + version = "1.0.228" 3824 + source = "registry+https://github.com/rust-lang/crates.io-index" 3825 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 3826 + dependencies = [ 3827 + "proc-macro2", 3828 + "quote", 3829 + "syn 2.0.108", 3830 + ] 3831 + 3832 + [[package]] 3833 + name = "serde_html_form" 3834 + version = "0.2.8" 3835 + source = "registry+https://github.com/rust-lang/crates.io-index" 3836 + checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" 3837 + dependencies = [ 3838 + "form_urlencoded", 3839 + "indexmap", 3840 + "itoa", 3841 + "ryu", 3842 + "serde_core", 3843 + ] 3844 + 3845 + [[package]] 3846 + name = "serde_ipld_dagcbor" 3847 + version = "0.6.4" 3848 + source = "registry+https://github.com/rust-lang/crates.io-index" 3849 + checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" 3850 + dependencies = [ 3851 + "cbor4ii", 3852 + "ipld-core", 3853 + "scopeguard", 3854 + "serde", 3855 + ] 3856 + 3857 + [[package]] 3858 + name = "serde_json" 3859 + version = "1.0.145" 3860 + source = "registry+https://github.com/rust-lang/crates.io-index" 3861 + checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 3862 + dependencies = [ 3863 + "itoa", 3864 + "memchr", 3865 + "ryu", 3866 + "serde", 3867 + "serde_core", 3868 + ] 3869 + 3870 + [[package]] 3871 + name = "serde_spanned" 3872 + version = "0.6.9" 3873 + source = "registry+https://github.com/rust-lang/crates.io-index" 3874 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 3875 + dependencies = [ 3876 + "serde", 3877 + ] 3878 + 3879 + [[package]] 3880 + name = "serde_urlencoded" 3881 + version = "0.7.1" 3882 + source = "registry+https://github.com/rust-lang/crates.io-index" 3883 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 3884 + dependencies = [ 3885 + "form_urlencoded", 3886 + "itoa", 3887 + "ryu", 3888 + "serde", 3889 + ] 3890 + 3891 + [[package]] 3892 + name = "sha1" 3893 + version = "0.10.6" 3894 + source = "registry+https://github.com/rust-lang/crates.io-index" 3895 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 3896 + dependencies = [ 3897 + "cfg-if", 3898 + "cpufeatures", 3899 + "digest", 3900 + ] 3901 + 3902 + [[package]] 3903 + name = "sha1_smol" 3904 + version = "1.0.1" 3905 + source = "registry+https://github.com/rust-lang/crates.io-index" 3906 + checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 3907 + 3908 + [[package]] 3909 + name = "sha2" 3910 + version = "0.10.9" 3911 + source = "registry+https://github.com/rust-lang/crates.io-index" 3912 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 3913 + dependencies = [ 3914 + "cfg-if", 3915 + "cpufeatures", 3916 + "digest", 3917 + ] 3918 + 3919 + [[package]] 3920 + name = "sharded-slab" 3921 + version = "0.1.7" 3922 + source = "registry+https://github.com/rust-lang/crates.io-index" 3923 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3924 + dependencies = [ 3925 + "lazy_static", 3926 + ] 3927 + 3928 + [[package]] 3929 + name = "shlex" 3930 + version = "1.3.0" 3931 + source = "registry+https://github.com/rust-lang/crates.io-index" 3932 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 3933 + 3934 + [[package]] 3935 + name = "signal-hook-registry" 3936 + version = "1.4.6" 3937 + source = "registry+https://github.com/rust-lang/crates.io-index" 3938 + checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 3939 + dependencies = [ 3940 + "libc", 3941 + ] 3942 + 3943 + [[package]] 3944 + name = "signature" 3945 + version = "2.2.0" 3946 + source = "registry+https://github.com/rust-lang/crates.io-index" 3947 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 3948 + dependencies = [ 3949 + "digest", 3950 + "rand_core 0.6.4", 3951 + ] 3952 + 3953 + [[package]] 3954 + name = "simd-adler32" 3955 + version = "0.3.7" 3956 + source = "registry+https://github.com/rust-lang/crates.io-index" 3957 + checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 3958 + 3959 + [[package]] 3960 + name = "simd_helpers" 3961 + version = "0.1.0" 3962 + source = "registry+https://github.com/rust-lang/crates.io-index" 3963 + checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" 3964 + dependencies = [ 3965 + "quote", 3966 + ] 3967 + 3968 + [[package]] 3969 + name = "similar" 3970 + version = "2.7.0" 3971 + source = "registry+https://github.com/rust-lang/crates.io-index" 3972 + checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 3973 + 3974 + [[package]] 3975 + name = "siphasher" 3976 + version = "1.0.1" 3977 + source = "registry+https://github.com/rust-lang/crates.io-index" 3978 + checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" 3979 + 3980 + [[package]] 3981 + name = "skywatch-phash-rs" 3982 + version = "0.1.0" 3983 + dependencies = [ 3984 + "chrono", 3985 + "dotenvy", 3986 + "futures", 3987 + "futures-util", 3988 + "governor", 3989 + "image", 3990 + "image_hasher", 3991 + "jacquard", 3992 + "jacquard-api", 3993 + "jacquard-common", 3994 + "jacquard-identity", 3995 + "jacquard-oauth", 3996 + "miette", 3997 + "mockito", 3998 + "redis", 3999 + "reqwest", 4000 + "serde", 4001 + "serde_json", 4002 + "thiserror 2.0.17", 4003 + "tokio", 4004 + "tokio-test", 4005 + "tracing", 4006 + "tracing-subscriber", 4007 + "url", 4008 + ] 4009 + 4010 + [[package]] 4011 + name = "slab" 4012 + version = "0.4.11" 4013 + source = "registry+https://github.com/rust-lang/crates.io-index" 4014 + checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 4015 + 4016 + [[package]] 4017 + name = "smallvec" 4018 + version = "1.15.1" 4019 + source = "registry+https://github.com/rust-lang/crates.io-index" 4020 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 4021 + 4022 + [[package]] 4023 + name = "smol_str" 4024 + version = "0.3.4" 4025 + source = "registry+https://github.com/rust-lang/crates.io-index" 4026 + checksum = "3498b0a27f93ef1402f20eefacfaa1691272ac4eca1cdc8c596cb0a245d6cbf5" 4027 + dependencies = [ 4028 + "borsh", 4029 + "serde_core", 4030 + ] 4031 + 4032 + [[package]] 4033 + name = "socket2" 4034 + version = "0.5.10" 4035 + source = "registry+https://github.com/rust-lang/crates.io-index" 4036 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 4037 + dependencies = [ 4038 + "libc", 4039 + "windows-sys 0.52.0", 4040 + ] 4041 + 4042 + [[package]] 4043 + name = "socket2" 4044 + version = "0.6.1" 4045 + source = "registry+https://github.com/rust-lang/crates.io-index" 4046 + checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" 4047 + dependencies = [ 4048 + "libc", 4049 + "windows-sys 0.60.2", 4050 + ] 4051 + 4052 + [[package]] 4053 + name = "spin" 4054 + version = "0.9.8" 4055 + source = "registry+https://github.com/rust-lang/crates.io-index" 4056 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 4057 + 4058 + [[package]] 4059 + name = "spin" 4060 + version = "0.10.0" 4061 + source = "registry+https://github.com/rust-lang/crates.io-index" 4062 + checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" 4063 + 4064 + [[package]] 4065 + name = "spinning_top" 4066 + version = "0.3.0" 4067 + source = "registry+https://github.com/rust-lang/crates.io-index" 4068 + checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" 4069 + dependencies = [ 4070 + "lock_api", 4071 + ] 4072 + 4073 + [[package]] 4074 + name = "spki" 4075 + version = "0.7.3" 4076 + source = "registry+https://github.com/rust-lang/crates.io-index" 4077 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 4078 + dependencies = [ 4079 + "base64ct", 4080 + "der", 4081 + ] 4082 + 4083 + [[package]] 4084 + name = "stable_deref_trait" 4085 + version = "1.2.1" 4086 + source = "registry+https://github.com/rust-lang/crates.io-index" 4087 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 4088 + 4089 + [[package]] 4090 + name = "static-regular-grammar" 4091 + version = "2.0.2" 4092 + source = "registry+https://github.com/rust-lang/crates.io-index" 4093 + checksum = "4f4a6c40247579acfbb138c3cd7de3dab113ab4ac6227f1b7de7d626ee667957" 4094 + dependencies = [ 4095 + "abnf", 4096 + "btree-range-map", 4097 + "ciborium", 4098 + "hex_fmt", 4099 + "indoc", 4100 + "proc-macro-error", 4101 + "proc-macro2", 4102 + "quote", 4103 + "serde", 4104 + "sha2", 4105 + "syn 2.0.108", 4106 + "thiserror 1.0.69", 4107 + ] 4108 + 4109 + [[package]] 4110 + name = "static_assertions" 4111 + version = "1.1.0" 4112 + source = "registry+https://github.com/rust-lang/crates.io-index" 4113 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 4114 + 4115 + [[package]] 4116 + name = "strength_reduce" 4117 + version = "0.2.4" 4118 + source = "registry+https://github.com/rust-lang/crates.io-index" 4119 + checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" 4120 + 4121 + [[package]] 4122 + name = "string_cache" 4123 + version = "0.8.9" 4124 + source = "registry+https://github.com/rust-lang/crates.io-index" 4125 + checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" 4126 + dependencies = [ 4127 + "new_debug_unreachable", 4128 + "parking_lot", 4129 + "phf_shared", 4130 + "precomputed-hash", 4131 + "serde", 4132 + ] 4133 + 4134 + [[package]] 4135 + name = "string_cache_codegen" 4136 + version = "0.5.4" 4137 + source = "registry+https://github.com/rust-lang/crates.io-index" 4138 + checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" 4139 + dependencies = [ 4140 + "phf_generator", 4141 + "phf_shared", 4142 + "proc-macro2", 4143 + "quote", 4144 + ] 4145 + 4146 + [[package]] 4147 + name = "strsim" 4148 + version = "0.11.1" 4149 + source = "registry+https://github.com/rust-lang/crates.io-index" 4150 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 4151 + 4152 + [[package]] 4153 + name = "subtle" 4154 + version = "2.6.1" 4155 + source = "registry+https://github.com/rust-lang/crates.io-index" 4156 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 4157 + 4158 + [[package]] 4159 + name = "supports-color" 4160 + version = "3.0.2" 4161 + source = "registry+https://github.com/rust-lang/crates.io-index" 4162 + checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" 4163 + dependencies = [ 4164 + "is_ci", 4165 + ] 4166 + 4167 + [[package]] 4168 + name = "supports-hyperlinks" 4169 + version = "3.1.0" 4170 + source = "registry+https://github.com/rust-lang/crates.io-index" 4171 + checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" 4172 + 4173 + [[package]] 4174 + name = "supports-unicode" 4175 + version = "3.0.0" 4176 + source = "registry+https://github.com/rust-lang/crates.io-index" 4177 + checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" 4178 + 4179 + [[package]] 4180 + name = "syn" 4181 + version = "1.0.109" 4182 + source = "registry+https://github.com/rust-lang/crates.io-index" 4183 + checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 4184 + dependencies = [ 4185 + "proc-macro2", 4186 + "quote", 4187 + "unicode-ident", 4188 + ] 4189 + 4190 + [[package]] 4191 + name = "syn" 4192 + version = "2.0.108" 4193 + source = "registry+https://github.com/rust-lang/crates.io-index" 4194 + checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" 4195 + dependencies = [ 4196 + "proc-macro2", 4197 + "quote", 4198 + "unicode-ident", 4199 + ] 4200 + 4201 + [[package]] 4202 + name = "sync_wrapper" 4203 + version = "1.0.2" 4204 + source = "registry+https://github.com/rust-lang/crates.io-index" 4205 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 4206 + dependencies = [ 4207 + "futures-core", 4208 + ] 4209 + 4210 + [[package]] 4211 + name = "synstructure" 4212 + version = "0.13.2" 4213 + source = "registry+https://github.com/rust-lang/crates.io-index" 4214 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 4215 + dependencies = [ 4216 + "proc-macro2", 4217 + "quote", 4218 + "syn 2.0.108", 4219 + ] 4220 + 4221 + [[package]] 4222 + name = "system-configuration" 4223 + version = "0.6.1" 4224 + source = "registry+https://github.com/rust-lang/crates.io-index" 4225 + checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 4226 + dependencies = [ 4227 + "bitflags", 4228 + "core-foundation 0.9.4", 4229 + "system-configuration-sys", 4230 + ] 4231 + 4232 + [[package]] 4233 + name = "system-configuration-sys" 4234 + version = "0.6.0" 4235 + source = "registry+https://github.com/rust-lang/crates.io-index" 4236 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 4237 + dependencies = [ 4238 + "core-foundation-sys", 4239 + "libc", 4240 + ] 4241 + 4242 + [[package]] 4243 + name = "system-deps" 4244 + version = "6.2.2" 4245 + source = "registry+https://github.com/rust-lang/crates.io-index" 4246 + checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" 4247 + dependencies = [ 4248 + "cfg-expr", 4249 + "heck 0.5.0", 4250 + "pkg-config", 4251 + "toml", 4252 + "version-compare", 4253 + ] 4254 + 4255 + [[package]] 4256 + name = "target-lexicon" 4257 + version = "0.12.16" 4258 + source = "registry+https://github.com/rust-lang/crates.io-index" 4259 + checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" 4260 + 4261 + [[package]] 4262 + name = "tempfile" 4263 + version = "3.23.0" 4264 + source = "registry+https://github.com/rust-lang/crates.io-index" 4265 + checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 4266 + dependencies = [ 4267 + "fastrand", 4268 + "getrandom 0.3.4", 4269 + "once_cell", 4270 + "rustix", 4271 + "windows-sys 0.61.2", 4272 + ] 4273 + 4274 + [[package]] 4275 + name = "tendril" 4276 + version = "0.4.3" 4277 + source = "registry+https://github.com/rust-lang/crates.io-index" 4278 + checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" 4279 + dependencies = [ 4280 + "futf", 4281 + "mac", 4282 + "utf-8", 4283 + ] 4284 + 4285 + [[package]] 4286 + name = "terminal_size" 4287 + version = "0.4.3" 4288 + source = "registry+https://github.com/rust-lang/crates.io-index" 4289 + checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" 4290 + dependencies = [ 4291 + "rustix", 4292 + "windows-sys 0.60.2", 4293 + ] 4294 + 4295 + [[package]] 4296 + name = "textwrap" 4297 + version = "0.16.2" 4298 + source = "registry+https://github.com/rust-lang/crates.io-index" 4299 + checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" 4300 + dependencies = [ 4301 + "unicode-linebreak", 4302 + "unicode-width 0.2.2", 4303 + ] 4304 + 4305 + [[package]] 4306 + name = "thiserror" 4307 + version = "1.0.69" 4308 + source = "registry+https://github.com/rust-lang/crates.io-index" 4309 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 4310 + dependencies = [ 4311 + "thiserror-impl 1.0.69", 4312 + ] 4313 + 4314 + [[package]] 4315 + name = "thiserror" 4316 + version = "2.0.17" 4317 + source = "registry+https://github.com/rust-lang/crates.io-index" 4318 + checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 4319 + dependencies = [ 4320 + "thiserror-impl 2.0.17", 4321 + ] 4322 + 4323 + [[package]] 4324 + name = "thiserror-impl" 4325 + version = "1.0.69" 4326 + source = "registry+https://github.com/rust-lang/crates.io-index" 4327 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 4328 + dependencies = [ 4329 + "proc-macro2", 4330 + "quote", 4331 + "syn 2.0.108", 4332 + ] 4333 + 4334 + [[package]] 4335 + name = "thiserror-impl" 4336 + version = "2.0.17" 4337 + source = "registry+https://github.com/rust-lang/crates.io-index" 4338 + checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 4339 + dependencies = [ 4340 + "proc-macro2", 4341 + "quote", 4342 + "syn 2.0.108", 4343 + ] 4344 + 4345 + [[package]] 4346 + name = "thread_local" 4347 + version = "1.1.9" 4348 + source = "registry+https://github.com/rust-lang/crates.io-index" 4349 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 4350 + dependencies = [ 4351 + "cfg-if", 4352 + ] 4353 + 4354 + [[package]] 4355 + name = "threadpool" 4356 + version = "1.8.1" 4357 + source = "registry+https://github.com/rust-lang/crates.io-index" 4358 + checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" 4359 + dependencies = [ 4360 + "num_cpus", 4361 + ] 4362 + 4363 + [[package]] 4364 + name = "tiff" 4365 + version = "0.10.3" 4366 + source = "registry+https://github.com/rust-lang/crates.io-index" 4367 + checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" 4368 + dependencies = [ 4369 + "fax", 4370 + "flate2", 4371 + "half", 4372 + "quick-error 2.0.1", 4373 + "weezl", 4374 + "zune-jpeg", 4375 + ] 4376 + 4377 + [[package]] 4378 + name = "time" 4379 + version = "0.3.44" 4380 + source = "registry+https://github.com/rust-lang/crates.io-index" 4381 + checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" 4382 + dependencies = [ 4383 + "deranged", 4384 + "libc", 4385 + "num-conv", 4386 + "num_threads", 4387 + "powerfmt", 4388 + "serde", 4389 + "time-core", 4390 + ] 4391 + 4392 + [[package]] 4393 + name = "time-core" 4394 + version = "0.1.6" 4395 + source = "registry+https://github.com/rust-lang/crates.io-index" 4396 + checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" 4397 + 4398 + [[package]] 4399 + name = "tiny_http" 4400 + version = "0.12.0" 4401 + source = "registry+https://github.com/rust-lang/crates.io-index" 4402 + checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" 4403 + dependencies = [ 4404 + "ascii", 4405 + "chunked_transfer", 4406 + "httpdate", 4407 + "log", 4408 + ] 4409 + 4410 + [[package]] 4411 + name = "tinystr" 4412 + version = "0.8.1" 4413 + source = "registry+https://github.com/rust-lang/crates.io-index" 4414 + checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" 4415 + dependencies = [ 4416 + "displaydoc", 4417 + "zerovec", 4418 + ] 4419 + 4420 + [[package]] 4421 + name = "tinyvec" 4422 + version = "1.10.0" 4423 + source = "registry+https://github.com/rust-lang/crates.io-index" 4424 + checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" 4425 + dependencies = [ 4426 + "tinyvec_macros", 4427 + ] 4428 + 4429 + [[package]] 4430 + name = "tinyvec_macros" 4431 + version = "0.1.1" 4432 + source = "registry+https://github.com/rust-lang/crates.io-index" 4433 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 4434 + 4435 + [[package]] 4436 + name = "tokio" 4437 + version = "1.48.0" 4438 + source = "registry+https://github.com/rust-lang/crates.io-index" 4439 + checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" 4440 + dependencies = [ 4441 + "bytes", 4442 + "libc", 4443 + "mio", 4444 + "parking_lot", 4445 + "pin-project-lite", 4446 + "signal-hook-registry", 4447 + "socket2 0.6.1", 4448 + "tokio-macros", 4449 + "windows-sys 0.61.2", 4450 + ] 4451 + 4452 + [[package]] 4453 + name = "tokio-macros" 4454 + version = "2.6.0" 4455 + source = "registry+https://github.com/rust-lang/crates.io-index" 4456 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 4457 + dependencies = [ 4458 + "proc-macro2", 4459 + "quote", 4460 + "syn 2.0.108", 4461 + ] 4462 + 4463 + [[package]] 4464 + name = "tokio-native-tls" 4465 + version = "0.3.1" 4466 + source = "registry+https://github.com/rust-lang/crates.io-index" 4467 + checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 4468 + dependencies = [ 4469 + "native-tls", 4470 + "tokio", 4471 + ] 4472 + 4473 + [[package]] 4474 + name = "tokio-rustls" 4475 + version = "0.26.4" 4476 + source = "registry+https://github.com/rust-lang/crates.io-index" 4477 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 4478 + dependencies = [ 4479 + "rustls", 4480 + "tokio", 4481 + ] 4482 + 4483 + [[package]] 4484 + name = "tokio-stream" 4485 + version = "0.1.17" 4486 + source = "registry+https://github.com/rust-lang/crates.io-index" 4487 + checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" 4488 + dependencies = [ 4489 + "futures-core", 4490 + "pin-project-lite", 4491 + "tokio", 4492 + ] 4493 + 4494 + [[package]] 4495 + name = "tokio-test" 4496 + version = "0.4.4" 4497 + source = "registry+https://github.com/rust-lang/crates.io-index" 4498 + checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" 4499 + dependencies = [ 4500 + "async-stream", 4501 + "bytes", 4502 + "futures-core", 4503 + "tokio", 4504 + "tokio-stream", 4505 + ] 4506 + 4507 + [[package]] 4508 + name = "tokio-tungstenite" 4509 + version = "0.24.0" 4510 + source = "registry+https://github.com/rust-lang/crates.io-index" 4511 + checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" 4512 + dependencies = [ 4513 + "futures-util", 4514 + "log", 4515 + "rustls", 4516 + "rustls-native-certs", 4517 + "rustls-pki-types", 4518 + "tokio", 4519 + "tokio-rustls", 4520 + "tungstenite", 4521 + ] 4522 + 4523 + [[package]] 4524 + name = "tokio-tungstenite-wasm" 4525 + version = "0.4.0" 4526 + source = "registry+https://github.com/rust-lang/crates.io-index" 4527 + checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae" 4528 + dependencies = [ 4529 + "futures-channel", 4530 + "futures-util", 4531 + "http", 4532 + "httparse", 4533 + "js-sys", 4534 + "rustls", 4535 + "thiserror 1.0.69", 4536 + "tokio", 4537 + "tokio-tungstenite", 4538 + "wasm-bindgen", 4539 + "web-sys", 4540 + ] 4541 + 4542 + [[package]] 4543 + name = "tokio-util" 4544 + version = "0.7.16" 4545 + source = "registry+https://github.com/rust-lang/crates.io-index" 4546 + checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" 4547 + dependencies = [ 4548 + "bytes", 4549 + "futures-core", 4550 + "futures-sink", 4551 + "futures-util", 4552 + "pin-project-lite", 4553 + "tokio", 4554 + ] 4555 + 4556 + [[package]] 4557 + name = "toml" 4558 + version = "0.8.23" 4559 + source = "registry+https://github.com/rust-lang/crates.io-index" 4560 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 4561 + dependencies = [ 4562 + "serde", 4563 + "serde_spanned", 4564 + "toml_datetime", 4565 + "toml_edit", 4566 + ] 4567 + 4568 + [[package]] 4569 + name = "toml_datetime" 4570 + version = "0.6.11" 4571 + source = "registry+https://github.com/rust-lang/crates.io-index" 4572 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 4573 + dependencies = [ 4574 + "serde", 4575 + ] 4576 + 4577 + [[package]] 4578 + name = "toml_edit" 4579 + version = "0.22.27" 4580 + source = "registry+https://github.com/rust-lang/crates.io-index" 4581 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 4582 + dependencies = [ 4583 + "indexmap", 4584 + "serde", 4585 + "serde_spanned", 4586 + "toml_datetime", 4587 + "winnow", 4588 + ] 4589 + 4590 + [[package]] 4591 + name = "tower" 4592 + version = "0.5.2" 4593 + source = "registry+https://github.com/rust-lang/crates.io-index" 4594 + checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 4595 + dependencies = [ 4596 + "futures-core", 4597 + "futures-util", 4598 + "pin-project-lite", 4599 + "sync_wrapper", 4600 + "tokio", 4601 + "tower-layer", 4602 + "tower-service", 4603 + ] 4604 + 4605 + [[package]] 4606 + name = "tower-http" 4607 + version = "0.6.6" 4608 + source = "registry+https://github.com/rust-lang/crates.io-index" 4609 + checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 4610 + dependencies = [ 4611 + "bitflags", 4612 + "bytes", 4613 + "futures-util", 4614 + "http", 4615 + "http-body", 4616 + "iri-string", 4617 + "pin-project-lite", 4618 + "tower", 4619 + "tower-layer", 4620 + "tower-service", 4621 + ] 4622 + 4623 + [[package]] 4624 + name = "tower-layer" 4625 + version = "0.3.3" 4626 + source = "registry+https://github.com/rust-lang/crates.io-index" 4627 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 4628 + 4629 + [[package]] 4630 + name = "tower-service" 4631 + version = "0.3.3" 4632 + source = "registry+https://github.com/rust-lang/crates.io-index" 4633 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 4634 + 4635 + [[package]] 4636 + name = "tracing" 4637 + version = "0.1.41" 4638 + source = "registry+https://github.com/rust-lang/crates.io-index" 4639 + checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 4640 + dependencies = [ 4641 + "pin-project-lite", 4642 + "tracing-attributes", 4643 + "tracing-core", 4644 + ] 4645 + 4646 + [[package]] 4647 + name = "tracing-attributes" 4648 + version = "0.1.30" 4649 + source = "registry+https://github.com/rust-lang/crates.io-index" 4650 + checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" 4651 + dependencies = [ 4652 + "proc-macro2", 4653 + "quote", 4654 + "syn 2.0.108", 4655 + ] 4656 + 4657 + [[package]] 4658 + name = "tracing-core" 4659 + version = "0.1.34" 4660 + source = "registry+https://github.com/rust-lang/crates.io-index" 4661 + checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 4662 + dependencies = [ 4663 + "once_cell", 4664 + "valuable", 4665 + ] 4666 + 4667 + [[package]] 4668 + name = "tracing-log" 4669 + version = "0.2.0" 4670 + source = "registry+https://github.com/rust-lang/crates.io-index" 4671 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 4672 + dependencies = [ 4673 + "log", 4674 + "once_cell", 4675 + "tracing-core", 4676 + ] 4677 + 4678 + [[package]] 4679 + name = "tracing-serde" 4680 + version = "0.2.0" 4681 + source = "registry+https://github.com/rust-lang/crates.io-index" 4682 + checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" 4683 + dependencies = [ 4684 + "serde", 4685 + "tracing-core", 4686 + ] 4687 + 4688 + [[package]] 4689 + name = "tracing-subscriber" 4690 + version = "0.3.20" 4691 + source = "registry+https://github.com/rust-lang/crates.io-index" 4692 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 4693 + dependencies = [ 4694 + "matchers", 4695 + "nu-ansi-term", 4696 + "once_cell", 4697 + "regex-automata", 4698 + "serde", 4699 + "serde_json", 4700 + "sharded-slab", 4701 + "smallvec", 4702 + "thread_local", 4703 + "tracing", 4704 + "tracing-core", 4705 + "tracing-log", 4706 + "tracing-serde", 4707 + ] 4708 + 4709 + [[package]] 4710 + name = "trait-variant" 4711 + version = "0.1.2" 4712 + source = "registry+https://github.com/rust-lang/crates.io-index" 4713 + checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" 4714 + dependencies = [ 4715 + "proc-macro2", 4716 + "quote", 4717 + "syn 2.0.108", 4718 + ] 4719 + 4720 + [[package]] 4721 + name = "transpose" 4722 + version = "0.2.3" 4723 + source = "registry+https://github.com/rust-lang/crates.io-index" 4724 + checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" 4725 + dependencies = [ 4726 + "num-integer", 4727 + "strength_reduce", 4728 + ] 4729 + 4730 + [[package]] 4731 + name = "try-lock" 4732 + version = "0.2.5" 4733 + source = "registry+https://github.com/rust-lang/crates.io-index" 4734 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 4735 + 4736 + [[package]] 4737 + name = "tungstenite" 4738 + version = "0.24.0" 4739 + source = "registry+https://github.com/rust-lang/crates.io-index" 4740 + checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" 4741 + dependencies = [ 4742 + "byteorder", 4743 + "bytes", 4744 + "data-encoding", 4745 + "http", 4746 + "httparse", 4747 + "log", 4748 + "rand 0.8.5", 4749 + "rustls", 4750 + "rustls-pki-types", 4751 + "sha1", 4752 + "thiserror 1.0.69", 4753 + "utf-8", 4754 + ] 4755 + 4756 + [[package]] 4757 + name = "twoway" 4758 + version = "0.1.8" 4759 + source = "registry+https://github.com/rust-lang/crates.io-index" 4760 + checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" 4761 + dependencies = [ 4762 + "memchr", 4763 + ] 4764 + 4765 + [[package]] 4766 + name = "typenum" 4767 + version = "1.19.0" 4768 + source = "registry+https://github.com/rust-lang/crates.io-index" 4769 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 4770 + 4771 + [[package]] 4772 + name = "unicase" 4773 + version = "2.8.1" 4774 + source = "registry+https://github.com/rust-lang/crates.io-index" 4775 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 4776 + 4777 + [[package]] 4778 + name = "unicode-ident" 4779 + version = "1.0.20" 4780 + source = "registry+https://github.com/rust-lang/crates.io-index" 4781 + checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" 4782 + 4783 + [[package]] 4784 + name = "unicode-linebreak" 4785 + version = "0.1.5" 4786 + source = "registry+https://github.com/rust-lang/crates.io-index" 4787 + checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 4788 + 4789 + [[package]] 4790 + name = "unicode-width" 4791 + version = "0.1.14" 4792 + source = "registry+https://github.com/rust-lang/crates.io-index" 4793 + checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 4794 + 4795 + [[package]] 4796 + name = "unicode-width" 4797 + version = "0.2.2" 4798 + source = "registry+https://github.com/rust-lang/crates.io-index" 4799 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 4800 + 4801 + [[package]] 4802 + name = "unicode-xid" 4803 + version = "0.2.6" 4804 + source = "registry+https://github.com/rust-lang/crates.io-index" 4805 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 4806 + 4807 + [[package]] 4808 + name = "unsigned-varint" 4809 + version = "0.8.0" 4810 + source = "registry+https://github.com/rust-lang/crates.io-index" 4811 + checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 4812 + 4813 + [[package]] 4814 + name = "untrusted" 4815 + version = "0.9.0" 4816 + source = "registry+https://github.com/rust-lang/crates.io-index" 4817 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 4818 + 4819 + [[package]] 4820 + name = "url" 4821 + version = "2.5.7" 4822 + source = "registry+https://github.com/rust-lang/crates.io-index" 4823 + checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" 4824 + dependencies = [ 4825 + "form_urlencoded", 4826 + "idna", 4827 + "percent-encoding", 4828 + "serde", 4829 + ] 4830 + 4831 + [[package]] 4832 + name = "urlencoding" 4833 + version = "2.1.3" 4834 + source = "registry+https://github.com/rust-lang/crates.io-index" 4835 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 4836 + 4837 + [[package]] 4838 + name = "utf-8" 4839 + version = "0.7.6" 4840 + source = "registry+https://github.com/rust-lang/crates.io-index" 4841 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 4842 + 4843 + [[package]] 4844 + name = "utf8_iter" 4845 + version = "1.0.4" 4846 + source = "registry+https://github.com/rust-lang/crates.io-index" 4847 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 4848 + 4849 + [[package]] 4850 + name = "v_frame" 4851 + version = "0.3.9" 4852 + source = "registry+https://github.com/rust-lang/crates.io-index" 4853 + checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" 4854 + dependencies = [ 4855 + "aligned-vec", 4856 + "num-traits", 4857 + "wasm-bindgen", 4858 + ] 4859 + 4860 + [[package]] 4861 + name = "valuable" 4862 + version = "0.1.1" 4863 + source = "registry+https://github.com/rust-lang/crates.io-index" 4864 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 4865 + 4866 + [[package]] 4867 + name = "vcpkg" 4868 + version = "0.2.15" 4869 + source = "registry+https://github.com/rust-lang/crates.io-index" 4870 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 4871 + 4872 + [[package]] 4873 + name = "version-compare" 4874 + version = "0.2.0" 4875 + source = "registry+https://github.com/rust-lang/crates.io-index" 4876 + checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" 4877 + 4878 + [[package]] 4879 + name = "version_check" 4880 + version = "0.9.5" 4881 + source = "registry+https://github.com/rust-lang/crates.io-index" 4882 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 4883 + 4884 + [[package]] 4885 + name = "walkdir" 4886 + version = "2.5.0" 4887 + source = "registry+https://github.com/rust-lang/crates.io-index" 4888 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 4889 + dependencies = [ 4890 + "same-file", 4891 + "winapi-util", 4892 + ] 4893 + 4894 + [[package]] 4895 + name = "want" 4896 + version = "0.3.1" 4897 + source = "registry+https://github.com/rust-lang/crates.io-index" 4898 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 4899 + dependencies = [ 4900 + "try-lock", 4901 + ] 4902 + 4903 + [[package]] 4904 + name = "wasi" 4905 + version = "0.11.1+wasi-snapshot-preview1" 4906 + source = "registry+https://github.com/rust-lang/crates.io-index" 4907 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 4908 + 4909 + [[package]] 4910 + name = "wasip2" 4911 + version = "1.0.1+wasi-0.2.4" 4912 + source = "registry+https://github.com/rust-lang/crates.io-index" 4913 + checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 4914 + dependencies = [ 4915 + "wit-bindgen", 4916 + ] 4917 + 4918 + [[package]] 4919 + name = "wasm-bindgen" 4920 + version = "0.2.104" 4921 + source = "registry+https://github.com/rust-lang/crates.io-index" 4922 + checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" 4923 + dependencies = [ 4924 + "cfg-if", 4925 + "once_cell", 4926 + "rustversion", 4927 + "wasm-bindgen-macro", 4928 + "wasm-bindgen-shared", 4929 + ] 4930 + 4931 + [[package]] 4932 + name = "wasm-bindgen-backend" 4933 + version = "0.2.104" 4934 + source = "registry+https://github.com/rust-lang/crates.io-index" 4935 + checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" 4936 + dependencies = [ 4937 + "bumpalo", 4938 + "log", 4939 + "proc-macro2", 4940 + "quote", 4941 + "syn 2.0.108", 4942 + "wasm-bindgen-shared", 4943 + ] 4944 + 4945 + [[package]] 4946 + name = "wasm-bindgen-futures" 4947 + version = "0.4.54" 4948 + source = "registry+https://github.com/rust-lang/crates.io-index" 4949 + checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" 4950 + dependencies = [ 4951 + "cfg-if", 4952 + "js-sys", 4953 + "once_cell", 4954 + "wasm-bindgen", 4955 + "web-sys", 4956 + ] 4957 + 4958 + [[package]] 4959 + name = "wasm-bindgen-macro" 4960 + version = "0.2.104" 4961 + source = "registry+https://github.com/rust-lang/crates.io-index" 4962 + checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" 4963 + dependencies = [ 4964 + "quote", 4965 + "wasm-bindgen-macro-support", 4966 + ] 4967 + 4968 + [[package]] 4969 + name = "wasm-bindgen-macro-support" 4970 + version = "0.2.104" 4971 + source = "registry+https://github.com/rust-lang/crates.io-index" 4972 + checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" 4973 + dependencies = [ 4974 + "proc-macro2", 4975 + "quote", 4976 + "syn 2.0.108", 4977 + "wasm-bindgen-backend", 4978 + "wasm-bindgen-shared", 4979 + ] 4980 + 4981 + [[package]] 4982 + name = "wasm-bindgen-shared" 4983 + version = "0.2.104" 4984 + source = "registry+https://github.com/rust-lang/crates.io-index" 4985 + checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" 4986 + dependencies = [ 4987 + "unicode-ident", 4988 + ] 4989 + 4990 + [[package]] 4991 + name = "wasm-streams" 4992 + version = "0.4.2" 4993 + source = "registry+https://github.com/rust-lang/crates.io-index" 4994 + checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" 4995 + dependencies = [ 4996 + "futures-util", 4997 + "js-sys", 4998 + "wasm-bindgen", 4999 + "wasm-bindgen-futures", 5000 + "web-sys", 5001 + ] 5002 + 5003 + [[package]] 5004 + name = "web-sys" 5005 + version = "0.3.81" 5006 + source = "registry+https://github.com/rust-lang/crates.io-index" 5007 + checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" 5008 + dependencies = [ 5009 + "js-sys", 5010 + "wasm-bindgen", 5011 + ] 5012 + 5013 + [[package]] 5014 + name = "web-time" 5015 + version = "1.1.0" 5016 + source = "registry+https://github.com/rust-lang/crates.io-index" 5017 + checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 5018 + dependencies = [ 5019 + "js-sys", 5020 + "wasm-bindgen", 5021 + ] 5022 + 5023 + [[package]] 5024 + name = "webbrowser" 5025 + version = "0.8.15" 5026 + source = "registry+https://github.com/rust-lang/crates.io-index" 5027 + checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" 5028 + dependencies = [ 5029 + "core-foundation 0.9.4", 5030 + "home", 5031 + "jni", 5032 + "log", 5033 + "ndk-context", 5034 + "objc", 5035 + "raw-window-handle", 5036 + "url", 5037 + "web-sys", 5038 + ] 5039 + 5040 + [[package]] 5041 + name = "webpage" 5042 + version = "2.0.1" 5043 + source = "registry+https://github.com/rust-lang/crates.io-index" 5044 + checksum = "70862efc041d46e6bbaa82bb9c34ae0596d090e86cbd14bd9e93b36ee6802eac" 5045 + dependencies = [ 5046 + "html5ever", 5047 + "markup5ever_rcdom", 5048 + "serde_json", 5049 + "url", 5050 + ] 5051 + 5052 + [[package]] 5053 + name = "webpki-roots" 5054 + version = "1.0.3" 5055 + source = "registry+https://github.com/rust-lang/crates.io-index" 5056 + checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" 5057 + dependencies = [ 5058 + "rustls-pki-types", 5059 + ] 5060 + 5061 + [[package]] 5062 + name = "weezl" 5063 + version = "0.1.10" 5064 + source = "registry+https://github.com/rust-lang/crates.io-index" 5065 + checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" 5066 + 5067 + [[package]] 5068 + name = "widestring" 5069 + version = "1.2.1" 5070 + source = "registry+https://github.com/rust-lang/crates.io-index" 5071 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 5072 + 5073 + [[package]] 5074 + name = "winapi" 5075 + version = "0.3.9" 5076 + source = "registry+https://github.com/rust-lang/crates.io-index" 5077 + checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 5078 + dependencies = [ 5079 + "winapi-i686-pc-windows-gnu", 5080 + "winapi-x86_64-pc-windows-gnu", 5081 + ] 5082 + 5083 + [[package]] 5084 + name = "winapi-i686-pc-windows-gnu" 5085 + version = "0.4.0" 5086 + source = "registry+https://github.com/rust-lang/crates.io-index" 5087 + checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 5088 + 5089 + [[package]] 5090 + name = "winapi-util" 5091 + version = "0.1.11" 5092 + source = "registry+https://github.com/rust-lang/crates.io-index" 5093 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 5094 + dependencies = [ 5095 + "windows-sys 0.61.2", 5096 + ] 5097 + 5098 + [[package]] 5099 + name = "winapi-x86_64-pc-windows-gnu" 5100 + version = "0.4.0" 5101 + source = "registry+https://github.com/rust-lang/crates.io-index" 5102 + checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 5103 + 5104 + [[package]] 5105 + name = "windows" 5106 + version = "0.61.3" 5107 + source = "registry+https://github.com/rust-lang/crates.io-index" 5108 + checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" 5109 + dependencies = [ 5110 + "windows-collections", 5111 + "windows-core 0.61.2", 5112 + "windows-future", 5113 + "windows-link 0.1.3", 5114 + "windows-numerics", 5115 + ] 5116 + 5117 + [[package]] 5118 + name = "windows-collections" 5119 + version = "0.2.0" 5120 + source = "registry+https://github.com/rust-lang/crates.io-index" 5121 + checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" 5122 + dependencies = [ 5123 + "windows-core 0.61.2", 5124 + ] 5125 + 5126 + [[package]] 5127 + name = "windows-core" 5128 + version = "0.61.2" 5129 + source = "registry+https://github.com/rust-lang/crates.io-index" 5130 + checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 5131 + dependencies = [ 5132 + "windows-implement", 5133 + "windows-interface", 5134 + "windows-link 0.1.3", 5135 + "windows-result 0.3.4", 5136 + "windows-strings 0.4.2", 5137 + ] 5138 + 5139 + [[package]] 5140 + name = "windows-core" 5141 + version = "0.62.2" 5142 + source = "registry+https://github.com/rust-lang/crates.io-index" 5143 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 5144 + dependencies = [ 5145 + "windows-implement", 5146 + "windows-interface", 5147 + "windows-link 0.2.1", 5148 + "windows-result 0.4.1", 5149 + "windows-strings 0.5.1", 5150 + ] 5151 + 5152 + [[package]] 5153 + name = "windows-future" 5154 + version = "0.2.1" 5155 + source = "registry+https://github.com/rust-lang/crates.io-index" 5156 + checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" 5157 + dependencies = [ 5158 + "windows-core 0.61.2", 5159 + "windows-link 0.1.3", 5160 + "windows-threading", 5161 + ] 5162 + 5163 + [[package]] 5164 + name = "windows-implement" 5165 + version = "0.60.2" 5166 + source = "registry+https://github.com/rust-lang/crates.io-index" 5167 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 5168 + dependencies = [ 5169 + "proc-macro2", 5170 + "quote", 5171 + "syn 2.0.108", 5172 + ] 5173 + 5174 + [[package]] 5175 + name = "windows-interface" 5176 + version = "0.59.3" 5177 + source = "registry+https://github.com/rust-lang/crates.io-index" 5178 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 5179 + dependencies = [ 5180 + "proc-macro2", 5181 + "quote", 5182 + "syn 2.0.108", 5183 + ] 5184 + 5185 + [[package]] 5186 + name = "windows-link" 5187 + version = "0.1.3" 5188 + source = "registry+https://github.com/rust-lang/crates.io-index" 5189 + checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 5190 + 5191 + [[package]] 5192 + name = "windows-link" 5193 + version = "0.2.1" 5194 + source = "registry+https://github.com/rust-lang/crates.io-index" 5195 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 5196 + 5197 + [[package]] 5198 + name = "windows-numerics" 5199 + version = "0.2.0" 5200 + source = "registry+https://github.com/rust-lang/crates.io-index" 5201 + checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" 5202 + dependencies = [ 5203 + "windows-core 0.61.2", 5204 + "windows-link 0.1.3", 5205 + ] 5206 + 5207 + [[package]] 5208 + name = "windows-registry" 5209 + version = "0.5.3" 5210 + source = "registry+https://github.com/rust-lang/crates.io-index" 5211 + checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 5212 + dependencies = [ 5213 + "windows-link 0.1.3", 5214 + "windows-result 0.3.4", 5215 + "windows-strings 0.4.2", 5216 + ] 5217 + 5218 + [[package]] 5219 + name = "windows-result" 5220 + version = "0.3.4" 5221 + source = "registry+https://github.com/rust-lang/crates.io-index" 5222 + checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 5223 + dependencies = [ 5224 + "windows-link 0.1.3", 5225 + ] 5226 + 5227 + [[package]] 5228 + name = "windows-result" 5229 + version = "0.4.1" 5230 + source = "registry+https://github.com/rust-lang/crates.io-index" 5231 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 5232 + dependencies = [ 5233 + "windows-link 0.2.1", 5234 + ] 5235 + 5236 + [[package]] 5237 + name = "windows-strings" 5238 + version = "0.4.2" 5239 + source = "registry+https://github.com/rust-lang/crates.io-index" 5240 + checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 5241 + dependencies = [ 5242 + "windows-link 0.1.3", 5243 + ] 5244 + 5245 + [[package]] 5246 + name = "windows-strings" 5247 + version = "0.5.1" 5248 + source = "registry+https://github.com/rust-lang/crates.io-index" 5249 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 5250 + dependencies = [ 5251 + "windows-link 0.2.1", 5252 + ] 5253 + 5254 + [[package]] 5255 + name = "windows-sys" 5256 + version = "0.45.0" 5257 + source = "registry+https://github.com/rust-lang/crates.io-index" 5258 + checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 5259 + dependencies = [ 5260 + "windows-targets 0.42.2", 5261 + ] 5262 + 5263 + [[package]] 5264 + name = "windows-sys" 5265 + version = "0.48.0" 5266 + source = "registry+https://github.com/rust-lang/crates.io-index" 5267 + checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 5268 + dependencies = [ 5269 + "windows-targets 0.48.5", 5270 + ] 5271 + 5272 + [[package]] 5273 + name = "windows-sys" 5274 + version = "0.52.0" 5275 + source = "registry+https://github.com/rust-lang/crates.io-index" 5276 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 5277 + dependencies = [ 5278 + "windows-targets 0.52.6", 5279 + ] 5280 + 5281 + [[package]] 5282 + name = "windows-sys" 5283 + version = "0.59.0" 5284 + source = "registry+https://github.com/rust-lang/crates.io-index" 5285 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 5286 + dependencies = [ 5287 + "windows-targets 0.52.6", 5288 + ] 5289 + 5290 + [[package]] 5291 + name = "windows-sys" 5292 + version = "0.60.2" 5293 + source = "registry+https://github.com/rust-lang/crates.io-index" 5294 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 5295 + dependencies = [ 5296 + "windows-targets 0.53.5", 5297 + ] 5298 + 5299 + [[package]] 5300 + name = "windows-sys" 5301 + version = "0.61.2" 5302 + source = "registry+https://github.com/rust-lang/crates.io-index" 5303 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 5304 + dependencies = [ 5305 + "windows-link 0.2.1", 5306 + ] 5307 + 5308 + [[package]] 5309 + name = "windows-targets" 5310 + version = "0.42.2" 5311 + source = "registry+https://github.com/rust-lang/crates.io-index" 5312 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 5313 + dependencies = [ 5314 + "windows_aarch64_gnullvm 0.42.2", 5315 + "windows_aarch64_msvc 0.42.2", 5316 + "windows_i686_gnu 0.42.2", 5317 + "windows_i686_msvc 0.42.2", 5318 + "windows_x86_64_gnu 0.42.2", 5319 + "windows_x86_64_gnullvm 0.42.2", 5320 + "windows_x86_64_msvc 0.42.2", 5321 + ] 5322 + 5323 + [[package]] 5324 + name = "windows-targets" 5325 + version = "0.48.5" 5326 + source = "registry+https://github.com/rust-lang/crates.io-index" 5327 + checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 5328 + dependencies = [ 5329 + "windows_aarch64_gnullvm 0.48.5", 5330 + "windows_aarch64_msvc 0.48.5", 5331 + "windows_i686_gnu 0.48.5", 5332 + "windows_i686_msvc 0.48.5", 5333 + "windows_x86_64_gnu 0.48.5", 5334 + "windows_x86_64_gnullvm 0.48.5", 5335 + "windows_x86_64_msvc 0.48.5", 5336 + ] 5337 + 5338 + [[package]] 5339 + name = "windows-targets" 5340 + version = "0.52.6" 5341 + source = "registry+https://github.com/rust-lang/crates.io-index" 5342 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 5343 + dependencies = [ 5344 + "windows_aarch64_gnullvm 0.52.6", 5345 + "windows_aarch64_msvc 0.52.6", 5346 + "windows_i686_gnu 0.52.6", 5347 + "windows_i686_gnullvm 0.52.6", 5348 + "windows_i686_msvc 0.52.6", 5349 + "windows_x86_64_gnu 0.52.6", 5350 + "windows_x86_64_gnullvm 0.52.6", 5351 + "windows_x86_64_msvc 0.52.6", 5352 + ] 5353 + 5354 + [[package]] 5355 + name = "windows-targets" 5356 + version = "0.53.5" 5357 + source = "registry+https://github.com/rust-lang/crates.io-index" 5358 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 5359 + dependencies = [ 5360 + "windows-link 0.2.1", 5361 + "windows_aarch64_gnullvm 0.53.1", 5362 + "windows_aarch64_msvc 0.53.1", 5363 + "windows_i686_gnu 0.53.1", 5364 + "windows_i686_gnullvm 0.53.1", 5365 + "windows_i686_msvc 0.53.1", 5366 + "windows_x86_64_gnu 0.53.1", 5367 + "windows_x86_64_gnullvm 0.53.1", 5368 + "windows_x86_64_msvc 0.53.1", 5369 + ] 5370 + 5371 + [[package]] 5372 + name = "windows-threading" 5373 + version = "0.1.0" 5374 + source = "registry+https://github.com/rust-lang/crates.io-index" 5375 + checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" 5376 + dependencies = [ 5377 + "windows-link 0.1.3", 5378 + ] 5379 + 5380 + [[package]] 5381 + name = "windows_aarch64_gnullvm" 5382 + version = "0.42.2" 5383 + source = "registry+https://github.com/rust-lang/crates.io-index" 5384 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 5385 + 5386 + [[package]] 5387 + name = "windows_aarch64_gnullvm" 5388 + version = "0.48.5" 5389 + source = "registry+https://github.com/rust-lang/crates.io-index" 5390 + checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 5391 + 5392 + [[package]] 5393 + name = "windows_aarch64_gnullvm" 5394 + version = "0.52.6" 5395 + source = "registry+https://github.com/rust-lang/crates.io-index" 5396 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 5397 + 5398 + [[package]] 5399 + name = "windows_aarch64_gnullvm" 5400 + version = "0.53.1" 5401 + source = "registry+https://github.com/rust-lang/crates.io-index" 5402 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 5403 + 5404 + [[package]] 5405 + name = "windows_aarch64_msvc" 5406 + version = "0.42.2" 5407 + source = "registry+https://github.com/rust-lang/crates.io-index" 5408 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 5409 + 5410 + [[package]] 5411 + name = "windows_aarch64_msvc" 5412 + version = "0.48.5" 5413 + source = "registry+https://github.com/rust-lang/crates.io-index" 5414 + checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 5415 + 5416 + [[package]] 5417 + name = "windows_aarch64_msvc" 5418 + version = "0.52.6" 5419 + source = "registry+https://github.com/rust-lang/crates.io-index" 5420 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 5421 + 5422 + [[package]] 5423 + name = "windows_aarch64_msvc" 5424 + version = "0.53.1" 5425 + source = "registry+https://github.com/rust-lang/crates.io-index" 5426 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 5427 + 5428 + [[package]] 5429 + name = "windows_i686_gnu" 5430 + version = "0.42.2" 5431 + source = "registry+https://github.com/rust-lang/crates.io-index" 5432 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 5433 + 5434 + [[package]] 5435 + name = "windows_i686_gnu" 5436 + version = "0.48.5" 5437 + source = "registry+https://github.com/rust-lang/crates.io-index" 5438 + checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 5439 + 5440 + [[package]] 5441 + name = "windows_i686_gnu" 5442 + version = "0.52.6" 5443 + source = "registry+https://github.com/rust-lang/crates.io-index" 5444 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 5445 + 5446 + [[package]] 5447 + name = "windows_i686_gnu" 5448 + version = "0.53.1" 5449 + source = "registry+https://github.com/rust-lang/crates.io-index" 5450 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 5451 + 5452 + [[package]] 5453 + name = "windows_i686_gnullvm" 5454 + version = "0.52.6" 5455 + source = "registry+https://github.com/rust-lang/crates.io-index" 5456 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 5457 + 5458 + [[package]] 5459 + name = "windows_i686_gnullvm" 5460 + version = "0.53.1" 5461 + source = "registry+https://github.com/rust-lang/crates.io-index" 5462 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 5463 + 5464 + [[package]] 5465 + name = "windows_i686_msvc" 5466 + version = "0.42.2" 5467 + source = "registry+https://github.com/rust-lang/crates.io-index" 5468 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 5469 + 5470 + [[package]] 5471 + name = "windows_i686_msvc" 5472 + version = "0.48.5" 5473 + source = "registry+https://github.com/rust-lang/crates.io-index" 5474 + checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 5475 + 5476 + [[package]] 5477 + name = "windows_i686_msvc" 5478 + version = "0.52.6" 5479 + source = "registry+https://github.com/rust-lang/crates.io-index" 5480 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 5481 + 5482 + [[package]] 5483 + name = "windows_i686_msvc" 5484 + version = "0.53.1" 5485 + source = "registry+https://github.com/rust-lang/crates.io-index" 5486 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 5487 + 5488 + [[package]] 5489 + name = "windows_x86_64_gnu" 5490 + version = "0.42.2" 5491 + source = "registry+https://github.com/rust-lang/crates.io-index" 5492 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 5493 + 5494 + [[package]] 5495 + name = "windows_x86_64_gnu" 5496 + version = "0.48.5" 5497 + source = "registry+https://github.com/rust-lang/crates.io-index" 5498 + checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 5499 + 5500 + [[package]] 5501 + name = "windows_x86_64_gnu" 5502 + version = "0.52.6" 5503 + source = "registry+https://github.com/rust-lang/crates.io-index" 5504 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 5505 + 5506 + [[package]] 5507 + name = "windows_x86_64_gnu" 5508 + version = "0.53.1" 5509 + source = "registry+https://github.com/rust-lang/crates.io-index" 5510 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 5511 + 5512 + [[package]] 5513 + name = "windows_x86_64_gnullvm" 5514 + version = "0.42.2" 5515 + source = "registry+https://github.com/rust-lang/crates.io-index" 5516 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 5517 + 5518 + [[package]] 5519 + name = "windows_x86_64_gnullvm" 5520 + version = "0.48.5" 5521 + source = "registry+https://github.com/rust-lang/crates.io-index" 5522 + checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 5523 + 5524 + [[package]] 5525 + name = "windows_x86_64_gnullvm" 5526 + version = "0.52.6" 5527 + source = "registry+https://github.com/rust-lang/crates.io-index" 5528 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 5529 + 5530 + [[package]] 5531 + name = "windows_x86_64_gnullvm" 5532 + version = "0.53.1" 5533 + source = "registry+https://github.com/rust-lang/crates.io-index" 5534 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 5535 + 5536 + [[package]] 5537 + name = "windows_x86_64_msvc" 5538 + version = "0.42.2" 5539 + source = "registry+https://github.com/rust-lang/crates.io-index" 5540 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 5541 + 5542 + [[package]] 5543 + name = "windows_x86_64_msvc" 5544 + version = "0.48.5" 5545 + source = "registry+https://github.com/rust-lang/crates.io-index" 5546 + checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 5547 + 5548 + [[package]] 5549 + name = "windows_x86_64_msvc" 5550 + version = "0.52.6" 5551 + source = "registry+https://github.com/rust-lang/crates.io-index" 5552 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 5553 + 5554 + [[package]] 5555 + name = "windows_x86_64_msvc" 5556 + version = "0.53.1" 5557 + source = "registry+https://github.com/rust-lang/crates.io-index" 5558 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 5559 + 5560 + [[package]] 5561 + name = "winnow" 5562 + version = "0.7.13" 5563 + source = "registry+https://github.com/rust-lang/crates.io-index" 5564 + checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" 5565 + dependencies = [ 5566 + "memchr", 5567 + ] 5568 + 5569 + [[package]] 5570 + name = "winreg" 5571 + version = "0.50.0" 5572 + source = "registry+https://github.com/rust-lang/crates.io-index" 5573 + checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 5574 + dependencies = [ 5575 + "cfg-if", 5576 + "windows-sys 0.48.0", 5577 + ] 5578 + 5579 + [[package]] 5580 + name = "wit-bindgen" 5581 + version = "0.46.0" 5582 + source = "registry+https://github.com/rust-lang/crates.io-index" 5583 + checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 5584 + 5585 + [[package]] 5586 + name = "writeable" 5587 + version = "0.6.1" 5588 + source = "registry+https://github.com/rust-lang/crates.io-index" 5589 + checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" 5590 + 5591 + [[package]] 5592 + name = "xml5ever" 5593 + version = "0.18.1" 5594 + source = "registry+https://github.com/rust-lang/crates.io-index" 5595 + checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" 5596 + dependencies = [ 5597 + "log", 5598 + "mac", 5599 + "markup5ever", 5600 + ] 5601 + 5602 + [[package]] 5603 + name = "yansi" 5604 + version = "1.0.1" 5605 + source = "registry+https://github.com/rust-lang/crates.io-index" 5606 + checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 5607 + 5608 + [[package]] 5609 + name = "yoke" 5610 + version = "0.8.0" 5611 + source = "registry+https://github.com/rust-lang/crates.io-index" 5612 + checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" 5613 + dependencies = [ 5614 + "serde", 5615 + "stable_deref_trait", 5616 + "yoke-derive", 5617 + "zerofrom", 5618 + ] 5619 + 5620 + [[package]] 5621 + name = "yoke-derive" 5622 + version = "0.8.0" 5623 + source = "registry+https://github.com/rust-lang/crates.io-index" 5624 + checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" 5625 + dependencies = [ 5626 + "proc-macro2", 5627 + "quote", 5628 + "syn 2.0.108", 5629 + "synstructure", 5630 + ] 5631 + 5632 + [[package]] 5633 + name = "zerocopy" 5634 + version = "0.8.27" 5635 + source = "registry+https://github.com/rust-lang/crates.io-index" 5636 + checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 5637 + dependencies = [ 5638 + "zerocopy-derive", 5639 + ] 5640 + 5641 + [[package]] 5642 + name = "zerocopy-derive" 5643 + version = "0.8.27" 5644 + source = "registry+https://github.com/rust-lang/crates.io-index" 5645 + checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 5646 + dependencies = [ 5647 + "proc-macro2", 5648 + "quote", 5649 + "syn 2.0.108", 5650 + ] 5651 + 5652 + [[package]] 5653 + name = "zerofrom" 5654 + version = "0.1.6" 5655 + source = "registry+https://github.com/rust-lang/crates.io-index" 5656 + checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 5657 + dependencies = [ 5658 + "zerofrom-derive", 5659 + ] 5660 + 5661 + [[package]] 5662 + name = "zerofrom-derive" 5663 + version = "0.1.6" 5664 + source = "registry+https://github.com/rust-lang/crates.io-index" 5665 + checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 5666 + dependencies = [ 5667 + "proc-macro2", 5668 + "quote", 5669 + "syn 2.0.108", 5670 + "synstructure", 5671 + ] 5672 + 5673 + [[package]] 5674 + name = "zeroize" 5675 + version = "1.8.2" 5676 + source = "registry+https://github.com/rust-lang/crates.io-index" 5677 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 5678 + dependencies = [ 5679 + "serde", 5680 + ] 5681 + 5682 + [[package]] 5683 + name = "zerotrie" 5684 + version = "0.2.2" 5685 + source = "registry+https://github.com/rust-lang/crates.io-index" 5686 + checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" 5687 + dependencies = [ 5688 + "displaydoc", 5689 + "yoke", 5690 + "zerofrom", 5691 + ] 5692 + 5693 + [[package]] 5694 + name = "zerovec" 5695 + version = "0.11.4" 5696 + source = "registry+https://github.com/rust-lang/crates.io-index" 5697 + checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" 5698 + dependencies = [ 5699 + "yoke", 5700 + "zerofrom", 5701 + "zerovec-derive", 5702 + ] 5703 + 5704 + [[package]] 5705 + name = "zerovec-derive" 5706 + version = "0.11.1" 5707 + source = "registry+https://github.com/rust-lang/crates.io-index" 5708 + checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" 5709 + dependencies = [ 5710 + "proc-macro2", 5711 + "quote", 5712 + "syn 2.0.108", 5713 + ] 5714 + 5715 + [[package]] 5716 + name = "zune-core" 5717 + version = "0.4.12" 5718 + source = "registry+https://github.com/rust-lang/crates.io-index" 5719 + checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" 5720 + 5721 + [[package]] 5722 + name = "zune-inflate" 5723 + version = "0.2.54" 5724 + source = "registry+https://github.com/rust-lang/crates.io-index" 5725 + checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" 5726 + dependencies = [ 5727 + "simd-adler32", 5728 + ] 5729 + 5730 + [[package]] 5731 + name = "zune-jpeg" 5732 + version = "0.4.21" 5733 + source = "registry+https://github.com/rust-lang/crates.io-index" 5734 + checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" 5735 + dependencies = [ 5736 + "zune-core", 5737 + ]
+69
Cargo.toml
··· 1 + [package] 2 + name = "skywatch-phash-rs" 3 + version = "0.1.0" 4 + edition = "2024" 5 + authors = ["Giulia <skywatch@skywatch.blue"] 6 + description = "Perceptual hash-based image moderation for Bluesky (Rust rewrite)" 7 + license = "MIT" 8 + 9 + [dependencies] 10 + # Async runtime 11 + tokio = { version = "1", features = ["full"] } 12 + futures-util = "0.3" 13 + 14 + # ATProto client (Jacquard) - using local path 15 + jacquard = { path = "../jacquard/crates/jacquard" } 16 + jacquard-api = { path = "../jacquard/crates/jacquard-api" } 17 + jacquard-common = { path = "../jacquard/crates/jacquard-common", features = ["websocket"] } 18 + jacquard-identity = { path = "../jacquard/crates/jacquard-identity" } 19 + jacquard-oauth = { path = "../jacquard/crates/jacquard-oauth" } 20 + 21 + # Serialization 22 + serde = { version = "1.0", features = ["derive"] } 23 + serde_json = "1.0" 24 + 25 + # HTTP client 26 + reqwest = { version = "0.12", features = ["json"] } 27 + 28 + # Redis 29 + redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } 30 + 31 + # Image processing 32 + image = "0.25" 33 + image_hasher = "3.0" 34 + 35 + # Error handling 36 + miette = { version = "7.6", features = ["fancy"] } 37 + thiserror = "2.0" 38 + 39 + # Logging 40 + tracing = "0.1" 41 + tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 42 + 43 + # Config 44 + dotenvy = "0.15" 45 + 46 + # Utilities 47 + chrono = "0.4" 48 + url = "2.5" 49 + futures = "0.3" 50 + 51 + # Rate limiting 52 + governor = "0.8" 53 + 54 + [dev-dependencies] 55 + mockito = "1" 56 + tokio-test = "0.4" 57 + 58 + [[bin]] 59 + name = "skywatch-phash" 60 + path = "src/main.rs" 61 + 62 + [profile.release] 63 + opt-level = 3 64 + lto = true 65 + codegen-units = 1 66 + strip = true 67 + 68 + [profile.dev] 69 + opt-level = 0
+29
Dockerfile
··· 1 + FROM rust:1.83 as builder 2 + 3 + WORKDIR /usr/src/app 4 + 5 + # Copy manifests 6 + COPY Cargo.toml Cargo.lock ./ 7 + 8 + # Copy source 9 + COPY src ./src 10 + 11 + # Build release binary 12 + RUN cargo build --release 13 + 14 + # Runtime stage 15 + FROM debian:bookworm-slim 16 + 17 + RUN apt-get update && apt-get install -y \ 18 + ca-certificates \ 19 + && rm -rf /var/lib/apt/lists/* 20 + 21 + WORKDIR /app 22 + 23 + # Copy binary from builder 24 + COPY --from=builder /usr/src/app/target/release/skywatch-phash-rs . 25 + 26 + # Copy rules directory 27 + COPY rules ./rules 28 + 29 + CMD ["./skywatch-phash-rs"]
+229
README.md
··· 1 + # skywatch-phash-rs 2 + 3 + Rust implementation of Bluesky image moderation service using perceptual hashing (aHash/average hash algorithm). 4 + 5 + Monitors Bluesky's Jetstream for posts with images, computes perceptual hashes, matches against known bad images, and takes automated moderation actions (label/report posts and accounts). 6 + 7 + ## Features 8 + 9 + - Real-time Jetstream subscription for post monitoring 10 + - Perceptual hash (aHash) computation for images 11 + - Configurable hamming distance thresholds per rule 12 + - Redis-backed job queue and phash caching 13 + - Concurrent worker pool for parallel processing 14 + - Automatic retry with dead letter queue 15 + - Metrics tracking and logging 16 + - Graceful shutdown handling 17 + 18 + ## Prerequisites 19 + 20 + - **For Docker deployment:** 21 + - Docker and Docker Compose 22 + - **For local development:** 23 + - Nix with flakes enabled (recommended), OR 24 + - Rust 1.83+ 25 + - **Required for all:** 26 + - Bluesky labeler account with app password 27 + 28 + ## Quick Start 29 + 30 + 1. **Clone and setup:** 31 + ```bash 32 + git clone <repo-url> 33 + cd skywatch-phash-rs 34 + ``` 35 + 36 + 2. **Configure environment:** 37 + ```bash 38 + cp .env.example .env 39 + # Edit .env and fill in your automod account credentials: 40 + # - AUTOMOD_HANDLE 41 + # - AUTOMOD_PASSWORD 42 + ``` 43 + 44 + 3. **Start the service:** 45 + ```bash 46 + docker compose up --build 47 + ``` 48 + 49 + 4. **Monitor logs:** 50 + ```bash 51 + docker compose logs -f app 52 + ``` 53 + 54 + 5. **Stop the service:** 55 + ```bash 56 + docker compose down 57 + ``` 58 + 59 + ## Phash CLI Tool 60 + 61 + Compute perceptual hash for a single image: 62 + 63 + ```bash 64 + # Using cargo 65 + cargo run --bin phash-cli path/to/image.jpg 66 + 67 + # Or build and run 68 + cargo build --release --bin phash-cli 69 + ./target/release/phash-cli image.png 70 + ``` 71 + 72 + Output is a 16-character hex string (64-bit hash): 73 + ``` 74 + e0e0e0e0e0fcfefe 75 + ``` 76 + 77 + Use this to generate hashes for your blob check rules. 78 + 79 + ## Configuration 80 + 81 + All configuration is via environment variables (see `.env.example`): 82 + 83 + ### Required Variables 84 + 85 + - `AUTOMOD_HANDLE` - Your automod account handle (e.g., automod.bsky.social) 86 + - `AUTOMOD_PASSWORD` - App password for automod account 87 + - `LABELER_DID` - DID of your main labeler account (e.g., skywatch.blue) 88 + - `OZONE_URL` - Ozone moderation service URL 89 + - `OZONE_PDS` - Ozone PDS endpoint (for authentication) 90 + 91 + ### Optional Variables 92 + 93 + - `PROCESSING_CONCURRENCY` (default: 4) - Max parallel job processing 94 + - `PHASH_HAMMING_THRESHOLD` (default: 5) - Global hamming distance threshold 95 + - `CACHE_ENABLED` (default: true) - Enable Redis phash caching 96 + - `CACHE_TTL_SECONDS` (default: 86400) - Cache TTL (24 hours) 97 + - `RETRY_ATTEMPTS` (default: 3) - Max retry attempts for failed jobs 98 + - `JETSTREAM_URL` - Jetstream websocket URL 99 + - `REDIS_URL` - Redis connection string 100 + 101 + ## Blob Check Rules 102 + 103 + Rules are defined in `rules/blobs.json`: 104 + 105 + ```json 106 + [ 107 + { 108 + "phashes": ["e0e0e0e0e0fcfefe", "9b9e00008f8fffff"], 109 + "label": "spam", 110 + "comment": "Known spam image detected", 111 + "reportAcct": false, 112 + "labelAcct": true, 113 + "reportPost": true, 114 + "toLabel": true, 115 + "hammingThreshold": 3, 116 + "description": "Optional description", 117 + "ignoreDID": ["did:plc:exempted-user"] 118 + } 119 + ] 120 + ``` 121 + 122 + ### Rule Fields 123 + 124 + - `phashes` - Array of 16-char hex hashes to match against 125 + - `label` - Label to apply (e.g., "spam", "csam", "troll") 126 + - `comment` - Comment for reports 127 + - `reportAcct` - Report the account 128 + - `labelAcct` - Label the account 129 + - `reportPost` - Report the post 130 + - `toLabel` - Label the post 131 + - `hammingThreshold` - Max hamming distance for match (overrides global) 132 + - `description` - Optional description (not used by system) 133 + - `ignoreDID` - Optional array of DIDs to skip 134 + 135 + ## Architecture 136 + 137 + ``` 138 + Jetstream WebSocket 139 + 140 + Job Channel (mpsc) 141 + 142 + Redis Queue (FIFO) 143 + 144 + Worker Pool (semaphore-controlled concurrency) 145 + 146 + ┌─────────────────┐ 147 + │ For each job: │ 148 + │ 1. Check cache │ 149 + │ 2. Download blob│ 150 + │ 3. Compute phash│ 151 + │ 4. Match rules │ 152 + │ 5. Take actions │ 153 + └─────────────────┘ 154 + 155 + Metrics Tracking 156 + ``` 157 + 158 + ### Components 159 + 160 + - **Jetstream Client** - Subscribes to Bluesky firehose, filters posts with images 161 + - **Job Queue** - Redis-backed FIFO queue with retry logic 162 + - **Worker Pool** - Configurable concurrency with semaphore control 163 + - **Phash Cache** - Redis-backed cache for computed hashes (reduces redundant work) 164 + - **Agent Session** - Authenticated session with automatic token refresh 165 + - **Metrics** - Lock-free atomic counters for monitoring 166 + 167 + ## Development 168 + 169 + ### Using Nix (Recommended) 170 + 171 + If you have Nix with flakes enabled: 172 + 173 + ```bash 174 + # Enter dev shell with all dependencies 175 + nix develop 176 + 177 + # Or use direnv for automatic environment loading 178 + direnv allow 179 + 180 + # Build the project 181 + nix build 182 + 183 + # Run the binary 184 + nix run 185 + ``` 186 + 187 + The Nix flake provides: 188 + - Rust toolchain (stable latest) 189 + - Native dependencies (OpenSSL, pkg-config) 190 + - Development tools (cargo-watch, redis) 191 + - Reproducible builds across Linux and macOS 192 + 193 + ### Without Nix 194 + 195 + Run locally without Docker: 196 + 197 + ```bash 198 + # Start Redis 199 + docker run -d -p 6379:6379 redis 200 + 201 + # Create .env 202 + cp .env.example .env 203 + # Edit .env with your credentials 204 + 205 + # Run service 206 + cargo run 207 + 208 + # Run tests 209 + cargo test 210 + 211 + # Run specific binary 212 + cargo run --bin phash-cli image.jpg 213 + ``` 214 + 215 + ## Metrics 216 + 217 + Logged every 60 seconds: 218 + 219 + - **Jobs**: received, processed, failed, retried 220 + - **Blobs**: processed, downloaded 221 + - **Matches**: found 222 + - **Cache**: hits, misses, hit rate 223 + - **Moderation**: posts/accounts labeled and reported 224 + 225 + Final metrics are logged on graceful shutdown (Ctrl+C). 226 + 227 + ## License 228 + 229 + See LICENSE file.
+15
docker-compose.yml
··· 1 + services: 2 + redis: 3 + image: redis:7-alpine 4 + ports: 5 + - "6379:6379" 6 + volumes: 7 + - redis-data:/data 8 + healthcheck: 9 + test: ["CMD", "redis-cli", "ping"] 10 + interval: 5s 11 + timeout: 3s 12 + retries: 5 13 + 14 + volumes: 15 + redis-data:
+42
rules/blobs.json
··· 1 + [ 2 + { 3 + "phashes": [ 4 + "07870707073f7f7f", 5 + "d9794408f1f3fffb", 6 + "0f093139797b7967", 7 + "fdedc3030100c0fd", 8 + "0f7f707dcc0c0600" 9 + ], 10 + "label": "troll", 11 + "comment": "Image is used in harrassment campaign targeting Will Stancil", 12 + "reportAcct": false, 13 + "labelAcct": true, 14 + "reportPost": false, 15 + "toLabel": true, 16 + "hammingThreshold": 3, 17 + "description": "Sample harassment image variants (placeholder hashes)", 18 + "ignoreDID": ["did:plc:7umvpuxe2vbrc3zrzuquzniu"] 19 + }, 20 + { 21 + "phashes": ["00fffd7cd8da5000"], 22 + "label": "maga-trump", 23 + "comment": "Pro-trump imagery", 24 + "reportAcct": true, 25 + "labelAcct": false, 26 + "reportPost": false, 27 + "toLabel": true, 28 + "hammingThreshold": 3, 29 + "description": "Sample harassment image variants (placeholder hashes)" 30 + }, 31 + { 32 + "phashes": ["3e7e6e561202627c"], 33 + "label": "sensual-alf", 34 + "comment": "Posting Alf", 35 + "reportAcct": false, 36 + "labelAcct": false, 37 + "reportPost": false, 38 + "toLabel": true, 39 + "hammingThreshold": 3, 40 + "description": "Sample harassment image variants (placeholder hashes)" 41 + } 42 + ]
+4
rust-toolchain.toml
··· 1 + [toolchain] 2 + channel = "stable" 3 + profile = "default" 4 + targets = [ "wasm32-unknown-unknown" ]
+68
src/agent/README.md
··· 1 + # Agent Module 2 + 3 + ## Purpose 4 + Manages authenticated sessions with the Bluesky PDS (Personal Data Server) for making moderation API calls. 5 + 6 + ## Key Components 7 + 8 + ### `session.rs` 9 + - **`AgentSession`** - Main session management struct 10 + - Wraps reqwest Client with automatic token management 11 + - Stores session tokens in Arc<RwLock<>> for thread-safe access 12 + - Provides automatic token refresh on expiry 13 + 14 + ### Key Methods 15 + 16 + - **`new(config, client)`** - Create new agent session 17 + - **`login()`** - Authenticate with PDS using handle + password from config 18 + - Calls `com.atproto.server.createSession` 19 + - Stores `access_jwt` and `refresh_jwt` 20 + - **`refresh()`** - Refresh expired access token using refresh_jwt 21 + - Calls `com.atproto.server.refreshSession` 22 + - **`get_token()`** - Get valid access token (refreshes if needed) 23 + - Used by all moderation functions before making API calls 24 + - **`did()`** - Get the labeler's DID from current session 25 + - **`ensure_authenticated()`** - Login if not already authenticated 26 + 27 + ### Session Flow 28 + 29 + ``` 30 + 1. AgentSession::new() -> Creates empty session 31 + 2. login() -> Authenticates and stores tokens 32 + 3. get_token() -> Returns access_jwt (auto-refreshes if expired) 33 + 4. Moderation functions use access_jwt in Authorization header 34 + ``` 35 + 36 + ### Token Storage 37 + 38 + Session tokens are stored in: 39 + ```rust 40 + Arc<RwLock<Option<CreateSessionResponse>>> 41 + ``` 42 + 43 + This allows: 44 + - Thread-safe concurrent access from multiple workers 45 + - Shared refresh state across all API calls 46 + - Automatic token updates visible to all workers 47 + 48 + ## Usage Pattern 49 + 50 + ```rust 51 + let agent = AgentSession::new(config, client); 52 + agent.login().await?; 53 + 54 + // Later in worker: 55 + let token = agent.get_token().await?; 56 + // Use token in API calls 57 + ``` 58 + 59 + ## Dependencies 60 + 61 + - `reqwest::Client` - HTTP client 62 + - `Config` - Labeler credentials (handle, password, DID) 63 + - `miette` - Error handling 64 + 65 + ## Related Modules 66 + 67 + - Used by: `queue/worker.rs` - Workers get tokens for moderation actions 68 + - Uses: `config` - For labeler credentials
+3
src/agent/mod.rs
··· 1 + pub mod session; 2 + 3 + pub use session::AgentSession;
+49
src/agent/session.rs
··· 1 + use miette::Result; 2 + use std::sync::Arc; 3 + use jacquard::client::{Agent, MemoryCredentialSession}; 4 + use jacquard::CowStr; 5 + 6 + use crate::config::Config; 7 + 8 + /// Agent session wrapper using Jacquard 9 + /// All internal types use 'static lifetime to enable Send + Sync across threads 10 + #[derive(Clone)] 11 + pub struct AgentSession { 12 + agent: Arc<Agent<MemoryCredentialSession>>, 13 + did: Arc<str>, 14 + } 15 + 16 + impl AgentSession { 17 + /// Create a new agent session by authenticating with handle and password 18 + pub async fn new(config: &Config) -> Result<Self> { 19 + tracing::info!("Logging in as {}", config.automod.handle); 20 + 21 + let handle = CowStr::from(config.automod.handle.clone()); 22 + let password = CowStr::from(config.automod.password.clone()); 23 + let pds = Some(CowStr::from(config.ozone.pds.clone())); 24 + 25 + let (session, auth) = MemoryCredentialSession::authenticated(handle, password, pds).await?; 26 + 27 + tracing::info!("Successfully logged in as {} ({})", auth.handle, auth.did); 28 + 29 + let did: Arc<str> = Arc::from(auth.did.to_string()); 30 + let agent = Arc::new(Agent::from(session)); 31 + 32 + Ok(Self { agent, did }) 33 + } 34 + 35 + /// Get the authenticated agent 36 + pub fn agent(&self) -> &Arc<Agent<MemoryCredentialSession>> { 37 + &self.agent 38 + } 39 + 40 + /// Get the authenticated DID 41 + pub fn did(&self) -> &str { 42 + &*self.did 43 + } 44 + } 45 + 46 + #[cfg(test)] 47 + mod tests { 48 + // Note: Integration tests require valid credentials 49 + }
+33
src/bin/phash-cli.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use std::env; 3 + use std::fs; 4 + use std::path::Path; 5 + 6 + fn main() -> Result<()> { 7 + let args: Vec<String> = env::args().collect(); 8 + 9 + if args.len() != 2 { 10 + eprintln!("Usage: {} <image-path>", args[0]); 11 + eprintln!("\nComputes perceptual hash (aHash/average hash) for an image."); 12 + eprintln!("Outputs 16-character hex string (64-bit hash)."); 13 + std::process::exit(1); 14 + } 15 + 16 + let image_path = Path::new(&args[1]); 17 + 18 + if !image_path.exists() { 19 + eprintln!("Error: File not found: {}", image_path.display()); 20 + std::process::exit(1); 21 + } 22 + 23 + // Read image bytes 24 + let image_bytes = fs::read(image_path).into_diagnostic()?; 25 + 26 + // Compute phash 27 + let phash = skywatch_phash_rs::processor::phash::compute_phash(&image_bytes)?; 28 + 29 + // Output just the hash 30 + println!("{}", phash); 31 + 32 + Ok(()) 33 + }
+106
src/bin/test-image.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use std::env; 3 + use std::fs; 4 + use std::path::Path; 5 + 6 + fn main() -> Result<()> { 7 + let args: Vec<String> = env::args().collect(); 8 + 9 + if args.len() != 2 { 10 + eprintln!("Usage: {} <image-path>", args[0]); 11 + eprintln!("\nTests an image against blob check rules."); 12 + eprintln!("Computes phash and checks for matches in rules/blobs.json"); 13 + std::process::exit(1); 14 + } 15 + 16 + let image_path = Path::new(&args[1]); 17 + 18 + if !image_path.exists() { 19 + eprintln!("Error: File not found: {}", image_path.display()); 20 + std::process::exit(1); 21 + } 22 + 23 + tokio::runtime::Runtime::new() 24 + .into_diagnostic()? 25 + .block_on(async { 26 + test_image(image_path).await 27 + }) 28 + } 29 + 30 + async fn test_image(image_path: &Path) -> Result<()> { 31 + // Load blob checks 32 + let rules_path = Path::new("rules/blobs.json"); 33 + if !rules_path.exists() { 34 + eprintln!("Error: rules/blobs.json not found"); 35 + std::process::exit(1); 36 + } 37 + 38 + println!("Loading rules from rules/blobs.json..."); 39 + let blob_checks = skywatch_phash_rs::processor::matcher::load_blob_checks(rules_path).await?; 40 + println!("Loaded {} blob check rules\n", blob_checks.len()); 41 + 42 + // Read and compute phash 43 + println!("Computing phash for: {}", image_path.display()); 44 + let image_bytes = fs::read(image_path).into_diagnostic()?; 45 + let phash = skywatch_phash_rs::processor::phash::compute_phash(&image_bytes)?; 46 + println!("Computed phash: {}\n", phash); 47 + 48 + // Check against rules 49 + println!("Checking against rules...\n"); 50 + let test_did = "did:plc:test123456789"; 51 + 52 + let mut found_match = false; 53 + for check in &blob_checks { 54 + // Check if test DID is ignored 55 + if let Some(ignore_list) = &check.ignore_did { 56 + if ignore_list.contains(&test_did.to_string()) { 57 + println!("⏭️ Skipped rule '{}' (test DID is ignored)", check.label); 58 + continue; 59 + } 60 + } 61 + 62 + let threshold = check.hamming_threshold.unwrap_or(5); 63 + 64 + // Check each phash in the rule 65 + for check_phash in &check.phashes { 66 + match skywatch_phash_rs::processor::phash::hamming_distance(&phash, check_phash) { 67 + Ok(distance) => { 68 + if distance <= threshold { 69 + found_match = true; 70 + println!("✅ MATCH FOUND!"); 71 + println!(" Rule: {}", check.label); 72 + println!(" Description: {}", check.description.as_ref().unwrap_or(&"N/A".to_string())); 73 + println!(" Matched phash: {}", check_phash); 74 + println!(" Hamming distance: {} (threshold: {})", distance, threshold); 75 + println!(" Actions:"); 76 + if check.to_label { 77 + println!(" - Label post with '{}'", check.label); 78 + } 79 + if check.report_post { 80 + println!(" - Report post"); 81 + } 82 + if check.label_acct { 83 + println!(" - Label account"); 84 + } 85 + if check.report_acct { 86 + println!(" - Report account"); 87 + } 88 + println!(); 89 + } else { 90 + println!("❌ No match for rule '{}' (distance: {}, threshold: {})", 91 + check.label, distance, threshold); 92 + } 93 + } 94 + Err(e) => { 95 + eprintln!("⚠️ Error computing distance for rule '{}': {}", check.label, e); 96 + } 97 + } 98 + } 99 + } 100 + 101 + if !found_match { 102 + println!("\n🔍 No matches found. This image is not flagged by any rules."); 103 + } 104 + 105 + Ok(()) 106 + }
+83
src/cache/README.md
··· 1 + # Cache Module 2 + 3 + ## Purpose 4 + Redis-backed cache for perceptual hashes to avoid recomputing hashes for images we've already processed. 5 + 6 + ## Key Components 7 + 8 + ### `mod.rs` 9 + - **`PhashCache`** - Redis connection wrapper for phash storage 10 + - Uses CID (Content Identifier) as key 11 + - Stores computed phash as value 12 + - TTL controlled by `config.cache.ttl` 13 + 14 + ### Key Methods 15 + 16 + - **`new(config)`** - Create cache with Redis connection 17 + - Connects to `config.redis.url` 18 + - Uses multiplexed async connection for concurrent access 19 + - **`get(cid)`** - Retrieve cached phash for a CID 20 + - Returns `Ok(Some(phash))` if cached 21 + - Returns `Ok(None)` if not in cache 22 + - **`set(cid, phash)`** - Store phash in cache 23 + - Sets TTL from config (default 24 hours) 24 + - Uses `SET` with `EX` for expiration 25 + 26 + ### Cache Flow 27 + 28 + ``` 29 + 1. Worker gets job with blob CID 30 + 2. cache.get(cid) -> Check if phash exists 31 + 3. If cache hit: 32 + - Use cached phash 33 + - Skip download & computation 34 + 4. If cache miss: 35 + - Download blob 36 + - Compute phash 37 + - cache.set(cid, phash) -> Store for next time 38 + ``` 39 + 40 + ### Key Design Decisions 41 + 42 + **Why cache by CID?** 43 + - CIDs are content-addressed (same content = same CID) 44 + - Same image will always have same CID across all posts 45 + - Deduplicates work when same image is posted multiple times 46 + 47 + **Why use Redis?** 48 + - Shared cache across all workers 49 + - Persists across service restarts 50 + - TTL handles cache invalidation automatically 51 + 52 + **TTL Strategy** 53 + - Default 24 hours (86400 seconds) 54 + - Balances memory usage vs recomputation 55 + - Can be disabled with `CACHE_ENABLED=false` 56 + 57 + ## Performance Impact 58 + 59 + With cache enabled: 60 + - **Cache hit** - ~1ms (Redis lookup) 61 + - **Cache miss** - ~200-500ms (download + compute + store) 62 + 63 + For frequently reposted images (spam), cache hit rate can be >90%, massively reducing load. 64 + 65 + ## Configuration 66 + 67 + ```env 68 + CACHE_ENABLED=true # Enable/disable cache 69 + CACHE_TTL_SECONDS=86400 # Cache TTL (24 hours) 70 + REDIS_URL=redis://localhost:6379 71 + ``` 72 + 73 + ## Dependencies 74 + 75 + - `redis` - Async Redis client with tokio support 76 + - `Config` - Cache settings 77 + - `miette` - Error handling 78 + 79 + ## Related Modules 80 + 81 + - Used by: `queue/worker.rs` - Workers check cache before processing blobs 82 + - Uses: `config` - For Redis URL and TTL settings 83 + - Works with: `metrics` - Tracks cache hit/miss rates
+123
src/cache/mod.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use redis::AsyncCommands; 3 + use tracing::{debug, info}; 4 + 5 + use crate::config::Config; 6 + 7 + /// Redis key prefix for phash cache 8 + const PHASH_CACHE_PREFIX: &str = "phash"; 9 + 10 + /// Phash cache for storing computed image hashes 11 + #[derive(Clone)] 12 + pub struct PhashCache { 13 + redis: redis::aio::MultiplexedConnection, 14 + ttl: u64, 15 + enabled: bool, 16 + } 17 + 18 + impl PhashCache { 19 + /// Create a new phash cache 20 + pub async fn new(config: &Config) -> Result<Self> { 21 + info!("Connecting to Redis: {}", config.redis.url); 22 + 23 + let client = redis::Client::open(config.redis.url.as_str()).into_diagnostic()?; 24 + let redis = client 25 + .get_multiplexed_async_connection() 26 + .await 27 + .into_diagnostic()?; 28 + 29 + info!("Connected to Redis, cache enabled: {}", config.cache.enabled); 30 + 31 + Ok(Self { 32 + redis, 33 + ttl: config.cache.ttl, 34 + enabled: config.cache.enabled, 35 + }) 36 + } 37 + 38 + /// Get cached phash for a blob CID 39 + pub async fn get(&mut self, cid: &str) -> Result<Option<String>> { 40 + if !self.enabled { 41 + return Ok(None); 42 + } 43 + 44 + let key = format!("{}:{}", PHASH_CACHE_PREFIX, cid); 45 + 46 + let result: Option<String> = self.redis.get(&key).await.into_diagnostic()?; 47 + 48 + if result.is_some() { 49 + debug!("Cache hit for CID: {}", cid); 50 + } else { 51 + debug!("Cache miss for CID: {}", cid); 52 + } 53 + 54 + Ok(result) 55 + } 56 + 57 + /// Set cached phash for a blob CID 58 + pub async fn set(&mut self, cid: &str, phash: &str) -> Result<()> { 59 + if !self.enabled { 60 + return Ok(()); 61 + } 62 + 63 + let key = format!("{}:{}", PHASH_CACHE_PREFIX, cid); 64 + 65 + let _: () = self 66 + .redis 67 + .set_ex(&key, phash, self.ttl) 68 + .await 69 + .into_diagnostic()?; 70 + 71 + debug!("Cached phash for CID: {} -> {}", cid, phash); 72 + 73 + Ok(()) 74 + } 75 + 76 + /// Delete cached phash for a blob CID 77 + pub async fn delete(&mut self, cid: &str) -> Result<()> { 78 + if !self.enabled { 79 + return Ok(()); 80 + } 81 + 82 + let key = format!("{}:{}", PHASH_CACHE_PREFIX, cid); 83 + 84 + let _: () = self.redis.del(&key).await.into_diagnostic()?; 85 + 86 + debug!("Deleted cached phash for CID: {}", cid); 87 + 88 + Ok(()) 89 + } 90 + 91 + /// Check if cache is enabled 92 + pub fn is_enabled(&self) -> bool { 93 + self.enabled 94 + } 95 + 96 + /// Get or compute phash with caching 97 + pub async fn get_or_compute<F, Fut>(&mut self, cid: &str, compute_fn: F) -> Result<String> 98 + where 99 + F: FnOnce() -> Fut, 100 + Fut: std::future::Future<Output = Result<String>>, 101 + { 102 + // Try to get from cache 103 + if let Some(cached) = self.get(cid).await? { 104 + return Ok(cached); 105 + } 106 + 107 + // Compute if not cached 108 + let phash = compute_fn().await?; 109 + 110 + // Store in cache 111 + self.set(cid, &phash).await?; 112 + 113 + Ok(phash) 114 + } 115 + } 116 + 117 + #[cfg(test)] 118 + mod tests { 119 + use super::*; 120 + 121 + // Note: These are integration tests that require a running Redis instance 122 + // Run with: cargo test --test cache -- --ignored 123 + }
+108
src/config/README.md
··· 1 + # Config Module 2 + 3 + ## Purpose 4 + Centralized configuration management from environment variables with sensible defaults. 5 + 6 + ## Key Components 7 + 8 + ### `mod.rs` 9 + - **`Config`** - Root configuration struct containing all sub-configs 10 + - **Sub-config structs**: 11 + - `JetstreamConfig` - Jetstream connection settings 12 + - `RedisConfig` - Redis connection 13 + - `ProcessingConfig` - Worker pool settings 14 + - `CacheConfig` - Cache behavior 15 + - `PdsConfig` - PDS endpoint 16 + - `PlcConfig` - PLC directory endpoint 17 + - `LabelerConfig` - Labeler credentials 18 + - `OzoneConfig` - Ozone moderation service 19 + - `ModerationConfig` - Moderation behavior 20 + - `PhashConfig` - Phash matching settings 21 + 22 + ### Key Methods 23 + 24 + - **`Config::from_env()`** - Load all configuration from environment 25 + - Calls `dotenvy::dotenv()` to load `.env` file 26 + - Falls back to defaults where provided 27 + - Returns error for missing required vars 28 + 29 + ### Environment Variables 30 + 31 + **Required:** 32 + ```env 33 + LABELER_DID=did:plc:xxxxx # Your labeler DID 34 + LABELER_HANDLE=labeler.bsky.social # Your labeler handle 35 + LABELER_PASSWORD=xxxx-xxxx-xxxx-xxxx # App password 36 + OZONE_URL=https://ozone.example.com 37 + OZONE_PDS=https://bsky.social 38 + MOD_DID=did:plc:xxxxx # Moderator DID 39 + ``` 40 + 41 + **Optional with defaults:** 42 + ```env 43 + JETSTREAM_URL=wss://jetstream.atproto.tools/subscribe 44 + REDIS_URL=redis://localhost:6379 45 + PDS_ENDPOINT=https://bsky.social 46 + PLC_ENDPOINT=https://plc.directory 47 + 48 + PROCESSING_CONCURRENCY=10 # Max concurrent workers 49 + RETRY_ATTEMPTS=3 # Max retries per job 50 + RETRY_DELAY_MS=1000 # Delay between retries 51 + RATE_LIMIT_MS=100 # Min ms between API calls 52 + 53 + CACHE_ENABLED=true # Enable phash cache 54 + CACHE_TTL_SECONDS=86400 # Cache TTL (24h) 55 + 56 + PHASH_HAMMING_THRESHOLD=3 # Default hamming threshold 57 + CURSOR_UPDATE_INTERVAL=10000 # Cursor save interval (ms) 58 + ``` 59 + 60 + ### Helper Functions 61 + 62 + - **`get_env(key, default)`** - Get string with optional default 63 + - **`get_env_u32/u64/usize(key, default)`** - Get numbers with defaults 64 + - **`get_env_bool(key, default)`** - Parse bool (accepts "true", "1", "yes") 65 + 66 + ### Configuration Flow 67 + 68 + ``` 69 + 1. Service starts 70 + 2. Config::from_env() loads .env file 71 + 3. Validates required vars exist 72 + 4. Applies defaults for optional vars 73 + 5. Config passed to all modules 74 + ``` 75 + 76 + ### Design Decisions 77 + 78 + **Why environment variables?** 79 + - 12-factor app pattern 80 + - Easy to configure in Docker/k8s 81 + - No config files to manage 82 + - Secrets stay out of code 83 + 84 + **Why dotenvy?** 85 + - Compatible with .env files 86 + - Only used for local dev (prod uses real env vars) 87 + - Simplifies local testing 88 + 89 + ## Usage Pattern 90 + 91 + ```rust 92 + // At startup: 93 + let config = Config::from_env()?; 94 + 95 + // Pass to modules: 96 + let agent = AgentSession::new(config.clone(), client); 97 + let cache = PhashCache::new(config.clone()).await?; 98 + ``` 99 + 100 + ## Dependencies 101 + 102 + - `dotenvy` - .env file loading 103 + - `miette` - Error handling 104 + 105 + ## Related Modules 106 + 107 + - Used by: All modules - everything needs config 108 + - Cloned extensively (all fields implement `Clone`)
+207
src/config/mod.rs
··· 1 + use miette::{Context, IntoDiagnostic, Result}; 2 + use std::env; 3 + 4 + #[derive(Debug, Clone)] 5 + pub struct Config { 6 + pub jetstream: JetstreamConfig, 7 + pub redis: RedisConfig, 8 + pub processing: ProcessingConfig, 9 + pub cache: CacheConfig, 10 + pub pds: PdsConfig, 11 + pub plc: PlcConfig, 12 + pub automod: AutomodConfig, 13 + pub ozone: OzoneConfig, 14 + pub moderation: ModerationConfig, 15 + pub phash: PhashConfig, 16 + } 17 + 18 + #[derive(Debug, Clone)] 19 + pub struct JetstreamConfig { 20 + pub url: String, 21 + pub wanted_collections: Vec<String>, 22 + pub cursor_update_interval: u64, 23 + } 24 + 25 + #[derive(Debug, Clone)] 26 + pub struct RedisConfig { 27 + pub url: String, 28 + } 29 + 30 + #[derive(Debug, Clone)] 31 + pub struct ProcessingConfig { 32 + pub concurrency: usize, 33 + pub retry_attempts: u32, 34 + pub retry_delay: u64, 35 + } 36 + 37 + #[derive(Debug, Clone)] 38 + pub struct CacheConfig { 39 + pub enabled: bool, 40 + pub ttl: u64, 41 + } 42 + 43 + #[derive(Debug, Clone)] 44 + pub struct PdsConfig { 45 + pub endpoint: String, 46 + } 47 + 48 + #[derive(Debug, Clone)] 49 + pub struct PlcConfig { 50 + pub endpoint: String, 51 + } 52 + 53 + #[derive(Debug, Clone)] 54 + pub struct AutomodConfig { 55 + pub handle: String, 56 + pub password: String, 57 + } 58 + 59 + #[derive(Debug, Clone)] 60 + pub struct OzoneConfig { 61 + pub url: String, 62 + pub pds: String, 63 + } 64 + 65 + #[derive(Debug, Clone)] 66 + pub struct ModerationConfig { 67 + pub labeler_did: String, 68 + pub rate_limit: u64, 69 + } 70 + 71 + #[derive(Debug, Clone)] 72 + pub struct PhashConfig { 73 + pub default_hamming_threshold: u32, 74 + } 75 + 76 + impl Config { 77 + /// Load configuration from environment variables 78 + pub fn from_env() -> Result<Self> { 79 + // Load .env file if it exists (ignore if missing) 80 + dotenvy::dotenv().ok(); 81 + 82 + Ok(Config { 83 + jetstream: JetstreamConfig { 84 + url: get_env( 85 + "JETSTREAM_URL", 86 + Some("wss://jetstream.atproto.tools/subscribe"), 87 + )?, 88 + wanted_collections: vec!["app.bsky.feed.post".to_string()], 89 + cursor_update_interval: get_env_u64("CURSOR_UPDATE_INTERVAL", 10_000), 90 + }, 91 + redis: RedisConfig { 92 + url: get_env("REDIS_URL", Some("redis://localhost:6379"))?, 93 + }, 94 + processing: ProcessingConfig { 95 + concurrency: get_env_usize("PROCESSING_CONCURRENCY", 10), 96 + retry_attempts: get_env_u32("RETRY_ATTEMPTS", 3), 97 + retry_delay: get_env_u64("RETRY_DELAY_MS", 1000), 98 + }, 99 + cache: CacheConfig { 100 + enabled: get_env_bool("CACHE_ENABLED", true), 101 + ttl: get_env_u64("CACHE_TTL_SECONDS", 86400), 102 + }, 103 + pds: PdsConfig { 104 + endpoint: get_env("PDS_ENDPOINT", Some("https://bsky.social"))?, 105 + }, 106 + plc: PlcConfig { 107 + endpoint: get_env("PLC_ENDPOINT", Some("https://plc.directory"))?, 108 + }, 109 + automod: AutomodConfig { 110 + handle: get_env("AUTOMOD_HANDLE", None) 111 + .context("AUTOMOD_HANDLE is required for authentication")?, 112 + password: get_env("AUTOMOD_PASSWORD", None) 113 + .context("AUTOMOD_PASSWORD is required for authentication")?, 114 + }, 115 + ozone: OzoneConfig { 116 + url: get_env("OZONE_URL", None).context("OZONE_URL is required")?, 117 + pds: get_env("OZONE_PDS", None).context("OZONE_PDS is required")?, 118 + }, 119 + moderation: ModerationConfig { 120 + labeler_did: get_env("LABELER_DID", None).context("LABELER_DID is required")?, 121 + rate_limit: get_env_u64("RATE_LIMIT_MS", 100), 122 + }, 123 + phash: PhashConfig { 124 + default_hamming_threshold: get_env_u32("PHASH_HAMMING_THRESHOLD", 3), 125 + }, 126 + }) 127 + } 128 + } 129 + 130 + /// Get environment variable with optional default 131 + fn get_env(key: &str, default: Option<&str>) -> Result<String> { 132 + env::var(key) 133 + .into_diagnostic() 134 + .or_else(|_| { 135 + default 136 + .ok_or_else(|| miette::miette!("Missing required environment variable: {}", key)) 137 + .map(String::from) 138 + }) 139 + } 140 + 141 + /// Get environment variable as u32 with default 142 + fn get_env_u32(key: &str, default: u32) -> u32 { 143 + env::var(key) 144 + .ok() 145 + .and_then(|v| v.parse().ok()) 146 + .unwrap_or(default) 147 + } 148 + 149 + /// Get environment variable as u64 with default 150 + fn get_env_u64(key: &str, default: u64) -> u64 { 151 + env::var(key) 152 + .ok() 153 + .and_then(|v| v.parse().ok()) 154 + .unwrap_or(default) 155 + } 156 + 157 + /// Get environment variable as usize with default 158 + fn get_env_usize(key: &str, default: usize) -> usize { 159 + env::var(key) 160 + .ok() 161 + .and_then(|v| v.parse().ok()) 162 + .unwrap_or(default) 163 + } 164 + 165 + /// Get environment variable as bool with default 166 + /// Accepts "true", "1", "yes" (case-insensitive) as true 167 + fn get_env_bool(key: &str, default: bool) -> bool { 168 + env::var(key) 169 + .ok() 170 + .map(|v| { 171 + let v = v.to_lowercase(); 172 + v == "true" || v == "1" || v == "yes" 173 + }) 174 + .unwrap_or(default) 175 + } 176 + 177 + #[cfg(test)] 178 + mod tests { 179 + use super::*; 180 + 181 + #[test] 182 + fn test_get_env_bool() { 183 + unsafe { 184 + std::env::set_var("TEST_BOOL_TRUE", "true"); 185 + std::env::set_var("TEST_BOOL_1", "1"); 186 + std::env::set_var("TEST_BOOL_YES", "yes"); 187 + std::env::set_var("TEST_BOOL_FALSE", "false"); 188 + std::env::set_var("TEST_BOOL_0", "0"); 189 + } 190 + 191 + assert!(get_env_bool("TEST_BOOL_TRUE", false)); 192 + assert!(get_env_bool("TEST_BOOL_1", false)); 193 + assert!(get_env_bool("TEST_BOOL_YES", false)); 194 + assert!(!get_env_bool("TEST_BOOL_FALSE", true)); 195 + assert!(!get_env_bool("TEST_BOOL_0", true)); 196 + assert!(get_env_bool("TEST_BOOL_MISSING", true)); 197 + } 198 + 199 + #[test] 200 + fn test_get_env_u32() { 201 + unsafe { 202 + std::env::set_var("TEST_U32", "42"); 203 + } 204 + assert_eq!(get_env_u32("TEST_U32", 0), 42); 205 + assert_eq!(get_env_u32("TEST_U32_MISSING", 99), 99); 206 + } 207 + }
+125
src/jetstream/README.md
··· 1 + # Jetstream Module 2 + 3 + ## Purpose 4 + Connects to Bluesky's Jetstream firehose to receive real-time post events and extract image blobs for processing. 5 + 6 + ## Key Components 7 + 8 + ### `mod.rs` 9 + - **`start_jetstream_client()`** - Main entry point 10 + - Establishes WebSocket connection to Jetstream 11 + - Subscribes to `app.bsky.feed.post` collection 12 + - Sends jobs to processing queue via mpsc channel 13 + - Handles cursor persistence for resume capability 14 + 15 + ### `events.rs` 16 + - **`JetstreamEvent`** - Deserialized Jetstream message 17 + - Contains `did`, `commit.record`, `commit.cid` 18 + - Record type: `app.bsky.feed.post` 19 + - **`extract_blobs()`** - Extract image CIDs from post record 20 + - Handles direct post images (`record.embed.images`) 21 + - Handles quoted posts with images (`record.embed.record.embed.images`) 22 + - Returns `Vec<BlobReference>` with CIDs and mime types 23 + 24 + ### `cursor.rs` 25 + - **`CursorManager`** - Persists Jetstream cursor to SQLite 26 + - Enables resume from last position after restart 27 + - Prevents reprocessing old events 28 + - Updates every `config.cursor_update_interval` ms 29 + 30 + ## Message Flow 31 + 32 + ``` 33 + 1. WebSocket connects to Jetstream 34 + 2. Subscribe to app.bsky.feed.post collection 35 + 3. For each message: 36 + a. Deserialize JetstreamEvent 37 + b. Extract blobs from post record 38 + c. If blobs found: 39 + - Create ImageJob 40 + - Send to job_tx channel 41 + d. Update cursor periodically 42 + 4. On disconnect: Reconnect with last cursor 43 + ``` 44 + 45 + ## Event Filtering 46 + 47 + **What gets processed:** 48 + - Posts with `app.bsky.feed.post` type 49 + - Contains images in `embed.images` or `embed.record.embed.images` 50 + - Commit type is "create" (new posts only) 51 + 52 + **What gets skipped:** 53 + - Posts without images 54 + - Deletes/updates (only creates) 55 + - Other record types (likes, reposts, etc.) 56 + 57 + ## Cursor Persistence 58 + 59 + The cursor marks our position in the firehose: 60 + ``` 61 + Initial connect -> No cursor (start from now) 62 + Save cursor -> Every 10 seconds (configurable) 63 + Restart -> Resume from saved cursor 64 + ``` 65 + 66 + **Storage:** SQLite database (`firehose_cursor.db`) 67 + **Why:** Prevents missing events during downtime 68 + 69 + ## Blob Extraction Logic 70 + 71 + ### Direct Images 72 + ```json 73 + { 74 + "embed": { 75 + "$type": "app.bsky.embed.images", 76 + "images": [ 77 + { 78 + "image": { "ref": { "$link": "bafkrei..." } }, 79 + "mimeType": "image/jpeg" 80 + } 81 + ] 82 + } 83 + } 84 + ``` 85 + 86 + ### Quoted Posts with Images 87 + ```json 88 + { 89 + "embed": { 90 + "$type": "app.bsky.embed.record#withMedia", 91 + "record": {...}, 92 + "media": { 93 + "images": [...] 94 + } 95 + } 96 + } 97 + ``` 98 + 99 + The `extract_blobs()` function handles both patterns. 100 + 101 + ## Error Handling 102 + 103 + - **WebSocket disconnect** - Auto-reconnect with cursor 104 + - **Parse errors** - Log and skip message 105 + - **Channel closed** - Shutdown gracefully 106 + 107 + ## Configuration 108 + 109 + ```env 110 + JETSTREAM_URL=wss://jetstream.atproto.tools/subscribe 111 + CURSOR_UPDATE_INTERVAL=10000 # Save cursor every 10s 112 + ``` 113 + 114 + ## Dependencies 115 + 116 + - `tokio-tungstenite` - WebSocket client 117 + - `serde_json` - JSON parsing 118 + - `tokio::sync::mpsc` - Job channel 119 + - `rusqlite` - Cursor persistence 120 + 121 + ## Related Modules 122 + 123 + - Sends to: `queue` - Jobs sent via mpsc channel 124 + - Uses: `types::ImageJob` - Job format 125 + - Uses: `config` - Jetstream URL and settings
+41
src/jetstream/cursor.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use std::fs; 3 + use std::path::Path; 4 + use tracing::{info, warn}; 5 + 6 + const CURSOR_FILE: &str = "firehose_cursor.db"; 7 + 8 + /// Read cursor from disk 9 + pub fn read_cursor() -> Option<i64> { 10 + let path = Path::new(CURSOR_FILE); 11 + 12 + if !path.exists() { 13 + info!("No cursor file found, starting from current"); 14 + return None; 15 + } 16 + 17 + match fs::read_to_string(path) { 18 + Ok(content) => { 19 + match content.trim().parse::<i64>() { 20 + Ok(cursor) => { 21 + info!("Loaded cursor from disk: {}", cursor); 22 + Some(cursor) 23 + } 24 + Err(e) => { 25 + warn!("Failed to parse cursor file: {}", e); 26 + None 27 + } 28 + } 29 + } 30 + Err(e) => { 31 + warn!("Failed to read cursor file: {}", e); 32 + None 33 + } 34 + } 35 + } 36 + 37 + /// Write cursor to disk 38 + pub fn write_cursor(cursor: i64) -> Result<()> { 39 + fs::write(CURSOR_FILE, cursor.to_string()).into_diagnostic()?; 40 + Ok(()) 41 + }
+155
src/jetstream/events.rs
··· 1 + use miette::Result; 2 + use serde_json::Value; 3 + 4 + use crate::types::BlobReference; 5 + 6 + /// Extract blob references from a post record 7 + /// 8 + /// Handles two cases: 9 + /// 1. Direct images: record.embed.images[].image.ref.$link 10 + /// 2. Quote posts with media: record.embed.media.images[].image.ref.$link 11 + pub fn extract_blobs_from_record(record: &Value) -> Result<Vec<BlobReference>> { 12 + let mut blobs = Vec::new(); 13 + 14 + let Some(embed) = record.get("embed") else { 15 + return Ok(blobs); 16 + }; 17 + 18 + // Case 1: Direct images (embed.images) 19 + if let Some(images) = embed.get("images").and_then(|v| v.as_array()) { 20 + for img in images { 21 + if let Some(blob_ref) = extract_blob_from_image(img) { 22 + blobs.push(blob_ref); 23 + } 24 + } 25 + } 26 + 27 + // Case 2: Quote posts with media (embed.media.images) 28 + if let Some(media) = embed.get("media") { 29 + if let Some(images) = media.get("images").and_then(|v| v.as_array()) { 30 + for img in images { 31 + if let Some(blob_ref) = extract_blob_from_image(img) { 32 + blobs.push(blob_ref); 33 + } 34 + } 35 + } 36 + } 37 + 38 + Ok(blobs) 39 + } 40 + 41 + /// Extract a single blob reference from an image object 42 + fn extract_blob_from_image(img: &Value) -> Option<BlobReference> { 43 + let image_obj = img.get("image")?; 44 + let ref_obj = image_obj.get("ref")?; 45 + let cid = ref_obj.get("$link")?.as_str()?; 46 + 47 + let mime_type = image_obj 48 + .get("mimeType") 49 + .and_then(|v| v.as_str()) 50 + .map(String::from); 51 + 52 + Some(BlobReference { 53 + cid: cid.to_string(), 54 + mime_type, 55 + }) 56 + } 57 + 58 + #[cfg(test)] 59 + mod tests { 60 + use super::*; 61 + use serde_json::json; 62 + 63 + #[test] 64 + fn test_extract_blobs_direct_images() { 65 + let record = json!({ 66 + "embed": { 67 + "$type": "app.bsky.embed.images", 68 + "images": [ 69 + { 70 + "alt": "Test image", 71 + "image": { 72 + "ref": { 73 + "$link": "bafyreiabc123" 74 + }, 75 + "mimeType": "image/jpeg" 76 + } 77 + } 78 + ] 79 + } 80 + }); 81 + 82 + let blobs = extract_blobs_from_record(&record).unwrap(); 83 + assert_eq!(blobs.len(), 1); 84 + assert_eq!(blobs[0].cid, "bafyreiabc123"); 85 + assert_eq!(blobs[0].mime_type.as_deref(), Some("image/jpeg")); 86 + } 87 + 88 + #[test] 89 + fn test_extract_blobs_quote_with_media() { 90 + let record = json!({ 91 + "embed": { 92 + "$type": "app.bsky.embed.recordWithMedia", 93 + "media": { 94 + "images": [ 95 + { 96 + "alt": "Test image", 97 + "image": { 98 + "ref": { 99 + "$link": "bafyreiabc456" 100 + }, 101 + "mimeType": "image/png" 102 + } 103 + } 104 + ] 105 + } 106 + } 107 + }); 108 + 109 + let blobs = extract_blobs_from_record(&record).unwrap(); 110 + assert_eq!(blobs.len(), 1); 111 + assert_eq!(blobs[0].cid, "bafyreiabc456"); 112 + assert_eq!(blobs[0].mime_type.as_deref(), Some("image/png")); 113 + } 114 + 115 + #[test] 116 + fn test_extract_blobs_no_embed() { 117 + let record = json!({ 118 + "text": "Just a text post" 119 + }); 120 + 121 + let blobs = extract_blobs_from_record(&record).unwrap(); 122 + assert_eq!(blobs.len(), 0); 123 + } 124 + 125 + #[test] 126 + fn test_extract_blobs_multiple_images() { 127 + let record = json!({ 128 + "embed": { 129 + "images": [ 130 + { 131 + "image": { 132 + "ref": { 133 + "$link": "bafyreiabc111" 134 + }, 135 + "mimeType": "image/jpeg" 136 + } 137 + }, 138 + { 139 + "image": { 140 + "ref": { 141 + "$link": "bafyreiabc222" 142 + }, 143 + "mimeType": "image/png" 144 + } 145 + } 146 + ] 147 + } 148 + }); 149 + 150 + let blobs = extract_blobs_from_record(&record).unwrap(); 151 + assert_eq!(blobs.len(), 2); 152 + assert_eq!(blobs[0].cid, "bafyreiabc111"); 153 + assert_eq!(blobs[1].cid, "bafyreiabc222"); 154 + } 155 + }
+191
src/jetstream/mod.rs
··· 1 + use jacquard_common::jetstream::{CommitOperation, JetstreamMessage, JetstreamParams}; 2 + use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient}; 3 + use miette::{IntoDiagnostic, Result}; 4 + use futures::StreamExt; 5 + use tokio::sync::mpsc; 6 + use tracing::{debug, error, info, warn}; 7 + use url::Url; 8 + 9 + use crate::types::ImageJob; 10 + 11 + pub mod cursor; 12 + pub mod events; 13 + 14 + pub struct JetstreamClient { 15 + url: Url, 16 + cursor: Option<i64>, 17 + } 18 + 19 + impl JetstreamClient { 20 + pub fn new(url: String, cursor: Option<i64>) -> Result<Self> { 21 + let url = Url::parse(&url).into_diagnostic()?; 22 + Ok(Self { url, cursor }) 23 + } 24 + 25 + /// Start jetstream subscription and send jobs to the provided channel 26 + pub async fn subscribe( 27 + self, 28 + job_sender: mpsc::UnboundedSender<ImageJob>, 29 + mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, 30 + ) -> Result<()> { 31 + info!("Connecting to Jetstream: {}", self.url); 32 + 33 + let client = TungsteniteSubscriptionClient::from_base_uri(self.url.clone()); 34 + 35 + // Configure subscription parameters 36 + let params = JetstreamParams { 37 + wanted_collections: Some(vec!["app.bsky.feed.post".to_string().into()]), 38 + wanted_dids: None, 39 + cursor: self.cursor, 40 + compress: None, 41 + max_message_size_bytes: None, 42 + require_hello: None, 43 + }; 44 + 45 + let stream = client.subscribe(&params).await.into_diagnostic()?; 46 + 47 + info!("Connected to Jetstream, streaming messages..."); 48 + 49 + // Convert to typed message stream 50 + let (_sink, mut messages) = stream.into_stream(); 51 + 52 + let mut message_count = 0u64; 53 + let mut last_cursor: Option<i64> = None; 54 + let mut cursor_update_interval = tokio::time::interval(std::time::Duration::from_secs(10)); 55 + 56 + loop { 57 + tokio::select! { 58 + Some(result) = messages.next() => { 59 + match result { 60 + Ok(msg) => { 61 + message_count += 1; 62 + if message_count % 1000 == 0 { 63 + debug!("Processed {} messages", message_count); 64 + } 65 + 66 + // Extract cursor from message 67 + let cursor = match &msg { 68 + JetstreamMessage::Commit { time_us, .. } => Some(*time_us), 69 + JetstreamMessage::Identity { time_us, .. } => Some(*time_us), 70 + JetstreamMessage::Account { time_us, .. } => Some(*time_us), 71 + }; 72 + 73 + if let Some(c) = cursor { 74 + last_cursor = Some(c); 75 + } 76 + 77 + if let Err(e) = self.process_message(msg, &job_sender) { 78 + error!("Error processing message: {}", e); 79 + } 80 + } 81 + Err(e) => { 82 + error!("Jetstream error: {}", e); 83 + } 84 + } 85 + } 86 + _ = cursor_update_interval.tick() => { 87 + if let Some(cursor) = last_cursor { 88 + if let Err(e) = cursor::write_cursor(cursor) { 89 + warn!("Failed to write cursor: {}", e); 90 + } else { 91 + debug!("Wrote cursor: {}", cursor); 92 + } 93 + } 94 + } 95 + _ = &mut shutdown_rx => { 96 + info!("Shutting down Jetstream client"); 97 + info!("Processed {} total messages", message_count); 98 + 99 + // Write final cursor 100 + if let Some(cursor) = last_cursor { 101 + if let Err(e) = cursor::write_cursor(cursor) { 102 + warn!("Failed to write final cursor: {}", e); 103 + } else { 104 + info!("Wrote final cursor: {}", cursor); 105 + } 106 + } 107 + 108 + break; 109 + } 110 + } 111 + } 112 + 113 + Ok(()) 114 + } 115 + 116 + fn process_message( 117 + &self, 118 + msg: JetstreamMessage, 119 + job_sender: &mpsc::UnboundedSender<ImageJob>, 120 + ) -> Result<()> { 121 + match msg { 122 + JetstreamMessage::Commit { 123 + did, 124 + time_us: _, 125 + commit, 126 + } => { 127 + // Only process create operations on posts 128 + if commit.collection.as_ref() != "app.bsky.feed.post" { 129 + return Ok(()); 130 + } 131 + 132 + if !matches!(commit.operation, CommitOperation::Create) { 133 + return Ok(()); 134 + } 135 + 136 + // Parse record to extract blobs (skip if no record) 137 + let Some(record_data) = &commit.record else { 138 + return Ok(()); 139 + }; 140 + 141 + // Convert Data to serde_json::Value 142 + let record_value: serde_json::Value = 143 + serde_json::to_value(record_data).into_diagnostic()?; 144 + let blobs = events::extract_blobs_from_record(&record_value)?; 145 + 146 + if blobs.is_empty() { 147 + return Ok(()); 148 + } 149 + 150 + let post_uri = format!("at://{}/{}/{}", did, commit.collection, commit.rkey); 151 + 152 + debug!( 153 + "Post with {} blob(s): {}", 154 + blobs.len(), 155 + post_uri 156 + ); 157 + 158 + // Create job 159 + let post_cid = commit 160 + .cid 161 + .as_ref() 162 + .map(|cid| cid.to_string()) 163 + .unwrap_or_default(); 164 + 165 + let job = ImageJob { 166 + post_uri: post_uri.clone(), 167 + post_cid, 168 + post_did: did.to_string(), 169 + blobs, 170 + timestamp: chrono::Utc::now().timestamp_millis(), 171 + attempts: 0, 172 + }; 173 + 174 + // Send to queue 175 + if let Err(e) = job_sender.send(job) { 176 + warn!("Failed to send job to queue: {}", e); 177 + } 178 + 179 + Ok(()) 180 + } 181 + JetstreamMessage::Identity { .. } => { 182 + // Ignore identity updates for now 183 + Ok(()) 184 + } 185 + JetstreamMessage::Account { .. } => { 186 + // Ignore account updates for now 187 + Ok(()) 188 + } 189 + } 190 + } 191 + }
+28
src/lib.rs
··· 1 + // Core modules 2 + pub mod config; 3 + pub mod types; 4 + 5 + // Processing modules 6 + pub mod processor; 7 + 8 + // Jetstream client 9 + pub mod jetstream; 10 + 11 + // Moderation actions 12 + pub mod moderation; 13 + 14 + // Agent/authentication 15 + pub mod agent; 16 + 17 + // Cache 18 + pub mod cache; 19 + 20 + // Queue 21 + pub mod queue; 22 + 23 + // Metrics 24 + pub mod metrics; 25 + 26 + // Re-export commonly used types 27 + pub use config::Config; 28 + pub use types::{BlobCheck, BlobReference, ImageJob, MatchResult};
+192
src/main.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use reqwest::Client; 3 + use std::path::Path; 4 + use std::time::Duration; 5 + use tokio::sync::mpsc; 6 + use tokio::time::interval; 7 + use tracing::{error, info}; 8 + 9 + use skywatch_phash_rs::{ 10 + agent::AgentSession, 11 + cache::PhashCache, 12 + config::Config, 13 + jetstream::JetstreamClient, 14 + metrics::Metrics, 15 + processor::matcher, 16 + queue::{JobQueue, WorkerPool}, 17 + }; 18 + 19 + #[tokio::main] 20 + async fn main() -> Result<()> { 21 + // Initialize tracing/logging 22 + tracing_subscriber::fmt() 23 + .with_env_filter( 24 + tracing_subscriber::EnvFilter::from_default_env() 25 + .add_directive(tracing::Level::INFO.into()), 26 + ) 27 + .init(); 28 + 29 + info!("skywatch-phash-rs starting..."); 30 + 31 + // Load configuration 32 + let config = Config::from_env()?; 33 + info!("Configuration loaded"); 34 + info!("Jetstream URL: {}", config.jetstream.url); 35 + info!("Redis URL: {}", config.redis.url); 36 + info!("PDS Endpoint: {}", config.pds.endpoint); 37 + info!("Processing concurrency: {}", config.processing.concurrency); 38 + 39 + // Create HTTP client 40 + let client = Client::builder() 41 + .timeout(Duration::from_secs(30)) 42 + .build() 43 + .into_diagnostic()?; 44 + 45 + // Create and authenticate agent 46 + info!("Authenticating labeler agent..."); 47 + let agent = AgentSession::new(&config).await?; 48 + info!("Agent authenticated as {}", config.automod.handle); 49 + 50 + // Load blob checks from rules file 51 + let rules_path = Path::new("rules/blobs.json"); 52 + info!("Loading blob checks from {:?}", rules_path); 53 + let blob_checks = matcher::load_blob_checks(rules_path).await?; 54 + info!("Loaded {} blob check rules", blob_checks.len()); 55 + 56 + // Create metrics tracker 57 + let metrics = Metrics::new(); 58 + info!("Metrics tracker initialized"); 59 + 60 + // Create cache 61 + let cache = PhashCache::new(&config).await?; 62 + info!("Cache initialized (enabled: {})", cache.is_enabled()); 63 + 64 + // Create job queue 65 + let queue = JobQueue::new(&config).await?; 66 + info!("Job queue initialized"); 67 + 68 + // Create worker pool 69 + let worker_pool = WorkerPool::new( 70 + config.clone(), 71 + client.clone(), 72 + agent.clone(), 73 + blob_checks.clone(), 74 + metrics.clone(), 75 + ); 76 + info!("Worker pool created with {} workers", config.processing.concurrency); 77 + 78 + // Create shutdown channels 79 + let (_shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); 80 + let (jetstream_shutdown_tx, jetstream_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); 81 + 82 + // Create job channel for jetstream -> queue 83 + let (job_tx, mut job_rx) = mpsc::unbounded_channel(); 84 + 85 + // Load cursor from disk 86 + let cursor = skywatch_phash_rs::jetstream::cursor::read_cursor(); 87 + if let Some(c) = cursor { 88 + info!("Resuming from cursor: {}", c); 89 + } 90 + 91 + // Start jetstream subscriber 92 + info!("Starting Jetstream subscriber..."); 93 + let jetstream_config = config.clone(); 94 + let jetstream_handle = tokio::spawn(async move { 95 + let jetstream = JetstreamClient::new(jetstream_config.jetstream.url.clone(), cursor) 96 + .expect("Failed to create Jetstream client"); 97 + 98 + if let Err(e) = jetstream.subscribe(job_tx, jetstream_shutdown_rx).await { 99 + error!("Jetstream subscriber failed: {}", e); 100 + } 101 + }); 102 + 103 + // Start job receiver (receives from jetstream, pushes to queue) 104 + info!("Starting job receiver..."); 105 + let mut queue_for_receiver = queue.clone(); 106 + let receiver_metrics = metrics.clone(); 107 + let receiver_handle = tokio::spawn(async move { 108 + while let Some(job) = job_rx.recv().await { 109 + receiver_metrics.inc_jobs_received(); 110 + if let Err(e) = queue_for_receiver.push(&job).await { 111 + error!("Failed to push job to queue: {}", e); 112 + receiver_metrics.inc_jobs_failed(); 113 + } 114 + } 115 + info!("Job receiver stopped"); 116 + }); 117 + 118 + // Start worker pool with N concurrent workers 119 + // Run directly without tokio::spawn to avoid HRTB lifetime issues with MemoryCredentialSession 120 + let concurrency = config.processing.concurrency; 121 + info!("Starting {} concurrent workers...", concurrency); 122 + 123 + let mut worker_futures = Vec::new(); 124 + let (broadcast_shutdown_tx, _) = tokio::sync::broadcast::channel(1); 125 + 126 + for worker_id in 0..concurrency { 127 + let worker_pool_clone = worker_pool.clone(); 128 + let queue_clone = queue.clone(); 129 + let cache_clone = cache.clone(); 130 + let worker_shutdown = broadcast_shutdown_tx.subscribe(); 131 + 132 + let worker_future = async move { 133 + info!("Worker {} starting", worker_id); 134 + if let Err(e) = worker_pool_clone.start(queue_clone, cache_clone, worker_shutdown).await { 135 + error!("Worker {} failed: {}", worker_id, e); 136 + } 137 + info!("Worker {} stopped", worker_id); 138 + }; 139 + 140 + worker_futures.push(worker_future); 141 + } 142 + 143 + let all_workers_future = futures::future::join_all(worker_futures); 144 + 145 + // Start metrics logger 146 + info!("Starting metrics logger..."); 147 + let metrics_for_logger = metrics.clone(); 148 + let metrics_handle = tokio::spawn(async move { 149 + let mut ticker = interval(Duration::from_secs(60)); 150 + loop { 151 + ticker.tick().await; 152 + metrics_for_logger.log_stats(); 153 + } 154 + }); 155 + 156 + // Wait for shutdown signal or task completion 157 + info!("Service started successfully, waiting for shutdown signal..."); 158 + tokio::select! { 159 + _ = tokio::signal::ctrl_c() => { 160 + info!("Received Ctrl+C, initiating graceful shutdown..."); 161 + } 162 + _ = shutdown_rx => { 163 + info!("Received shutdown signal"); 164 + } 165 + _ = all_workers_future => { 166 + info!("All workers completed"); 167 + } 168 + _ = jetstream_handle => { 169 + info!("Jetstream subscriber completed"); 170 + } 171 + } 172 + 173 + // Send shutdown signals to all tasks 174 + info!("Shutting down Jetstream subscriber..."); 175 + let _ = jetstream_shutdown_tx.send(()); 176 + 177 + info!("Shutting down workers..."); 178 + let _ = broadcast_shutdown_tx.send(()); 179 + 180 + // Stop metrics logger 181 + metrics_handle.abort(); 182 + 183 + // Stop job receiver 184 + receiver_handle.abort(); 185 + 186 + // Log final metrics 187 + info!("=== Final Metrics ==="); 188 + metrics.log_stats(); 189 + 190 + info!("Shutdown complete"); 191 + Ok(()) 192 + }
src/matcher/mod.rs

This is a binary file and will not be displayed.

+130
src/metrics/README.md
··· 1 + # Metrics Module 2 + 3 + ## Purpose 4 + Lock-free atomic counters for tracking system performance and moderation actions. 5 + 6 + ## Key Components 7 + 8 + ### `mod.rs` 9 + - **`Metrics`** - Collection of atomic counters 10 + - Thread-safe (Arc-wrapped) 11 + - Lock-free operations (AtomicU64) 12 + - Cheap to clone and share across workers 13 + 14 + ### Tracked Metrics 15 + 16 + **Job Processing:** 17 + - `jobs_received` - Total jobs from Jetstream 18 + - `jobs_processed` - Successfully completed jobs 19 + - `jobs_failed` - Failed jobs (after all retries) 20 + - `jobs_retried` - Jobs sent to retry queue 21 + 22 + **Blob Processing:** 23 + - `blobs_processed` - Total blobs checked 24 + - `blobs_downloaded` - Blobs fetched from CDN/PDS 25 + - `matches_found` - Phashes matching known bad images 26 + 27 + **Cache Performance:** 28 + - `cache_hits` - Phash found in cache 29 + - `cache_misses` - Phash not in cache (download needed) 30 + 31 + **Moderation Actions:** 32 + - `posts_labeled` - Posts labeled via Ozone 33 + - `posts_reported` - Posts reported to moderators 34 + - `accounts_labeled` - Accounts labeled 35 + - `accounts_reported` - Accounts reported 36 + 37 + ### Key Methods 38 + 39 + **Increment counters:** 40 + - `inc_jobs_received()` 41 + - `inc_jobs_processed()` 42 + - `inc_cache_hits()` 43 + - etc. 44 + 45 + **Read values:** 46 + - `snapshot()` - Get all current values as `MetricsSnapshot` 47 + - Returns struct with all counter values 48 + - Used for logging/monitoring 49 + 50 + **Computed metrics:** 51 + - `cache_hit_rate()` - Percentage of cache hits 52 + - Returns `hits / (hits + misses)` 53 + - 0.0 if no cache operations yet 54 + 55 + ## Atomic Operations 56 + 57 + Uses `AtomicU64` with `Ordering::Relaxed`: 58 + - **Why Relaxed?** - Only need atomicity, not ordering guarantees 59 + - **Performance** - Fastest atomic operations 60 + - **Safe** - Multiple workers can increment concurrently 61 + 62 + Example: 63 + ```rust 64 + // Thread-safe increment from any worker 65 + metrics.inc_jobs_processed(); 66 + 67 + // Lock-free read 68 + let processed = metrics.jobs_processed.load(Ordering::Relaxed); 69 + ``` 70 + 71 + ## Logging Pattern 72 + 73 + Metrics are logged: 74 + 1. **Periodically** - Every 60 seconds while running 75 + 2. **On shutdown** - Final totals when service stops 76 + 77 + Log format: 78 + ``` 79 + Metrics snapshot: 80 + Jobs: received=1234, processed=1200, failed=5, retried=29 81 + Blobs: processed=3456, downloaded=890 82 + Cache: hits=2566 (74.3%), misses=890 83 + Matches: found=42 84 + Moderation: posts_labeled=38, posts_reported=42, accounts_labeled=10, accounts_reported=12 85 + ``` 86 + 87 + ## Usage Pattern 88 + 89 + ```rust 90 + // Create once at startup 91 + let metrics = Metrics::new(); 92 + 93 + // Clone into workers 94 + let worker_metrics = metrics.clone(); 95 + 96 + // Increment from workers 97 + worker_metrics.inc_jobs_processed(); 98 + worker_metrics.inc_cache_hits(); 99 + 100 + // Read snapshot for logging 101 + let snapshot = metrics.snapshot(); 102 + info!("Cache hit rate: {:.1}%", snapshot.cache_hit_rate() * 100.0); 103 + ``` 104 + 105 + ## Design Decisions 106 + 107 + **Why AtomicU64 instead of Mutex<u64>?** 108 + - No lock contention across 10+ workers 109 + - Much faster for increment operations 110 + - Can't overflow in practice (u64 max = 18 quintillion) 111 + 112 + **Why snapshot()?** 113 + - Get consistent view of all metrics at once 114 + - Prevents reading torn values during logging 115 + - Easy to extend with new computed metrics 116 + 117 + **Why Relaxed ordering?** 118 + - Counters are independent 119 + - Don't need cross-counter synchronization 120 + - Exact timing doesn't matter (eventually consistent) 121 + 122 + ## Dependencies 123 + 124 + - `std::sync::atomic` - Atomic operations 125 + - `std::sync::Arc` - Shared ownership 126 + 127 + ## Related Modules 128 + 129 + - Used by: All modules - everything increments metrics 130 + - Read by: `main.rs` - Periodic logging task
+352
src/metrics/mod.rs
··· 1 + use std::sync::atomic::{AtomicU64, Ordering}; 2 + use std::sync::Arc; 3 + use tracing::info; 4 + 5 + /// Global metrics tracker for the service 6 + #[derive(Clone)] 7 + pub struct Metrics { 8 + inner: Arc<MetricsInner>, 9 + } 10 + 11 + struct MetricsInner { 12 + // Job metrics 13 + jobs_received: AtomicU64, 14 + jobs_processed: AtomicU64, 15 + jobs_failed: AtomicU64, 16 + jobs_retried: AtomicU64, 17 + 18 + // Blob metrics 19 + blobs_processed: AtomicU64, 20 + blobs_downloaded: AtomicU64, 21 + 22 + // Match metrics 23 + matches_found: AtomicU64, 24 + 25 + // Cache metrics 26 + cache_hits: AtomicU64, 27 + cache_misses: AtomicU64, 28 + 29 + // Moderation metrics 30 + posts_labeled: AtomicU64, 31 + posts_reported: AtomicU64, 32 + accounts_labeled: AtomicU64, 33 + accounts_reported: AtomicU64, 34 + 35 + // Skip metrics (already handled) 36 + posts_already_labeled: AtomicU64, 37 + posts_already_reported: AtomicU64, 38 + accounts_already_labeled: AtomicU64, 39 + accounts_already_reported: AtomicU64, 40 + } 41 + 42 + impl Metrics { 43 + /// Create a new metrics tracker 44 + pub fn new() -> Self { 45 + Self { 46 + inner: Arc::new(MetricsInner { 47 + jobs_received: AtomicU64::new(0), 48 + jobs_processed: AtomicU64::new(0), 49 + jobs_failed: AtomicU64::new(0), 50 + jobs_retried: AtomicU64::new(0), 51 + blobs_processed: AtomicU64::new(0), 52 + blobs_downloaded: AtomicU64::new(0), 53 + matches_found: AtomicU64::new(0), 54 + cache_hits: AtomicU64::new(0), 55 + cache_misses: AtomicU64::new(0), 56 + posts_labeled: AtomicU64::new(0), 57 + posts_reported: AtomicU64::new(0), 58 + accounts_labeled: AtomicU64::new(0), 59 + accounts_reported: AtomicU64::new(0), 60 + posts_already_labeled: AtomicU64::new(0), 61 + posts_already_reported: AtomicU64::new(0), 62 + accounts_already_labeled: AtomicU64::new(0), 63 + accounts_already_reported: AtomicU64::new(0), 64 + }), 65 + } 66 + } 67 + 68 + // Job metrics 69 + pub fn inc_jobs_received(&self) { 70 + self.inner.jobs_received.fetch_add(1, Ordering::Relaxed); 71 + } 72 + 73 + pub fn inc_jobs_processed(&self) { 74 + self.inner.jobs_processed.fetch_add(1, Ordering::Relaxed); 75 + } 76 + 77 + pub fn inc_jobs_failed(&self) { 78 + self.inner.jobs_failed.fetch_add(1, Ordering::Relaxed); 79 + } 80 + 81 + pub fn inc_jobs_retried(&self) { 82 + self.inner.jobs_retried.fetch_add(1, Ordering::Relaxed); 83 + } 84 + 85 + // Blob metrics 86 + pub fn inc_blobs_processed(&self) { 87 + self.inner.blobs_processed.fetch_add(1, Ordering::Relaxed); 88 + } 89 + 90 + pub fn inc_blobs_downloaded(&self) { 91 + self.inner.blobs_downloaded.fetch_add(1, Ordering::Relaxed); 92 + } 93 + 94 + // Match metrics 95 + pub fn inc_matches_found(&self) { 96 + self.inner.matches_found.fetch_add(1, Ordering::Relaxed); 97 + } 98 + 99 + // Cache metrics 100 + pub fn inc_cache_hits(&self) { 101 + self.inner.cache_hits.fetch_add(1, Ordering::Relaxed); 102 + } 103 + 104 + pub fn inc_cache_misses(&self) { 105 + self.inner.cache_misses.fetch_add(1, Ordering::Relaxed); 106 + } 107 + 108 + // Moderation metrics 109 + pub fn inc_posts_labeled(&self) { 110 + self.inner.posts_labeled.fetch_add(1, Ordering::Relaxed); 111 + } 112 + 113 + pub fn inc_posts_reported(&self) { 114 + self.inner.posts_reported.fetch_add(1, Ordering::Relaxed); 115 + } 116 + 117 + pub fn inc_accounts_labeled(&self) { 118 + self.inner.accounts_labeled.fetch_add(1, Ordering::Relaxed); 119 + } 120 + 121 + pub fn inc_accounts_reported(&self) { 122 + self.inner.accounts_reported.fetch_add(1, Ordering::Relaxed); 123 + } 124 + 125 + // Skip metrics 126 + pub fn inc_posts_already_labeled(&self) { 127 + self.inner.posts_already_labeled.fetch_add(1, Ordering::Relaxed); 128 + } 129 + 130 + pub fn inc_posts_already_reported(&self) { 131 + self.inner.posts_already_reported.fetch_add(1, Ordering::Relaxed); 132 + } 133 + 134 + pub fn inc_accounts_already_labeled(&self) { 135 + self.inner.accounts_already_labeled.fetch_add(1, Ordering::Relaxed); 136 + } 137 + 138 + pub fn inc_accounts_already_reported(&self) { 139 + self.inner.accounts_already_reported.fetch_add(1, Ordering::Relaxed); 140 + } 141 + 142 + // Getters 143 + pub fn jobs_received(&self) -> u64 { 144 + self.inner.jobs_received.load(Ordering::Relaxed) 145 + } 146 + 147 + pub fn jobs_processed(&self) -> u64 { 148 + self.inner.jobs_processed.load(Ordering::Relaxed) 149 + } 150 + 151 + pub fn jobs_failed(&self) -> u64 { 152 + self.inner.jobs_failed.load(Ordering::Relaxed) 153 + } 154 + 155 + pub fn jobs_retried(&self) -> u64 { 156 + self.inner.jobs_retried.load(Ordering::Relaxed) 157 + } 158 + 159 + pub fn blobs_processed(&self) -> u64 { 160 + self.inner.blobs_processed.load(Ordering::Relaxed) 161 + } 162 + 163 + pub fn blobs_downloaded(&self) -> u64 { 164 + self.inner.blobs_downloaded.load(Ordering::Relaxed) 165 + } 166 + 167 + pub fn matches_found(&self) -> u64 { 168 + self.inner.matches_found.load(Ordering::Relaxed) 169 + } 170 + 171 + pub fn cache_hits(&self) -> u64 { 172 + self.inner.cache_hits.load(Ordering::Relaxed) 173 + } 174 + 175 + pub fn cache_misses(&self) -> u64 { 176 + self.inner.cache_misses.load(Ordering::Relaxed) 177 + } 178 + 179 + pub fn posts_labeled(&self) -> u64 { 180 + self.inner.posts_labeled.load(Ordering::Relaxed) 181 + } 182 + 183 + pub fn posts_reported(&self) -> u64 { 184 + self.inner.posts_reported.load(Ordering::Relaxed) 185 + } 186 + 187 + pub fn accounts_labeled(&self) -> u64 { 188 + self.inner.accounts_labeled.load(Ordering::Relaxed) 189 + } 190 + 191 + pub fn accounts_reported(&self) -> u64 { 192 + self.inner.accounts_reported.load(Ordering::Relaxed) 193 + } 194 + 195 + pub fn posts_already_labeled(&self) -> u64 { 196 + self.inner.posts_already_labeled.load(Ordering::Relaxed) 197 + } 198 + 199 + pub fn posts_already_reported(&self) -> u64 { 200 + self.inner.posts_already_reported.load(Ordering::Relaxed) 201 + } 202 + 203 + pub fn accounts_already_labeled(&self) -> u64 { 204 + self.inner.accounts_already_labeled.load(Ordering::Relaxed) 205 + } 206 + 207 + pub fn accounts_already_reported(&self) -> u64 { 208 + self.inner.accounts_already_reported.load(Ordering::Relaxed) 209 + } 210 + 211 + /// Log current metrics 212 + pub fn log_stats(&self) { 213 + info!("=== Metrics ==="); 214 + info!("Jobs: received={}, processed={}, failed={}, retried={}", 215 + self.jobs_received(), 216 + self.jobs_processed(), 217 + self.jobs_failed(), 218 + self.jobs_retried() 219 + ); 220 + info!("Blobs: processed={}, downloaded={}", 221 + self.blobs_processed(), 222 + self.blobs_downloaded() 223 + ); 224 + info!("Matches: found={}", 225 + self.matches_found() 226 + ); 227 + info!("Cache: hits={}, misses={}, hit_rate={:.2}%", 228 + self.cache_hits(), 229 + self.cache_misses(), 230 + self.cache_hit_rate() 231 + ); 232 + info!("Moderation: posts_labeled={}, posts_reported={}, accounts_labeled={}, accounts_reported={}", 233 + self.posts_labeled(), 234 + self.posts_reported(), 235 + self.accounts_labeled(), 236 + self.accounts_reported() 237 + ); 238 + info!("Skipped (deduplication): posts_already_labeled={}, posts_already_reported={}, accounts_already_labeled={}, accounts_already_reported={}", 239 + self.posts_already_labeled(), 240 + self.posts_already_reported(), 241 + self.accounts_already_labeled(), 242 + self.accounts_already_reported() 243 + ); 244 + } 245 + 246 + /// Calculate cache hit rate 247 + pub fn cache_hit_rate(&self) -> f64 { 248 + let hits = self.cache_hits() as f64; 249 + let total = (self.cache_hits() + self.cache_misses()) as f64; 250 + if total == 0.0 { 251 + 0.0 252 + } else { 253 + (hits / total) * 100.0 254 + } 255 + } 256 + 257 + /// Get snapshot of current metrics 258 + pub fn snapshot(&self) -> MetricsSnapshot { 259 + MetricsSnapshot { 260 + jobs_received: self.jobs_received(), 261 + jobs_processed: self.jobs_processed(), 262 + jobs_failed: self.jobs_failed(), 263 + jobs_retried: self.jobs_retried(), 264 + blobs_processed: self.blobs_processed(), 265 + blobs_downloaded: self.blobs_downloaded(), 266 + matches_found: self.matches_found(), 267 + cache_hits: self.cache_hits(), 268 + cache_misses: self.cache_misses(), 269 + posts_labeled: self.posts_labeled(), 270 + posts_reported: self.posts_reported(), 271 + accounts_labeled: self.accounts_labeled(), 272 + accounts_reported: self.accounts_reported(), 273 + posts_already_labeled: self.posts_already_labeled(), 274 + posts_already_reported: self.posts_already_reported(), 275 + accounts_already_labeled: self.accounts_already_labeled(), 276 + accounts_already_reported: self.accounts_already_reported(), 277 + } 278 + } 279 + } 280 + 281 + impl Default for Metrics { 282 + fn default() -> Self { 283 + Self::new() 284 + } 285 + } 286 + 287 + #[derive(Debug, Clone)] 288 + pub struct MetricsSnapshot { 289 + pub jobs_received: u64, 290 + pub jobs_processed: u64, 291 + pub jobs_failed: u64, 292 + pub jobs_retried: u64, 293 + pub blobs_processed: u64, 294 + pub blobs_downloaded: u64, 295 + pub matches_found: u64, 296 + pub cache_hits: u64, 297 + pub cache_misses: u64, 298 + pub posts_labeled: u64, 299 + pub posts_reported: u64, 300 + pub accounts_labeled: u64, 301 + pub accounts_reported: u64, 302 + pub posts_already_labeled: u64, 303 + pub posts_already_reported: u64, 304 + pub accounts_already_labeled: u64, 305 + pub accounts_already_reported: u64, 306 + } 307 + 308 + #[cfg(test)] 309 + mod tests { 310 + use super::*; 311 + 312 + #[test] 313 + fn test_metrics_increment() { 314 + let metrics = Metrics::new(); 315 + 316 + assert_eq!(metrics.jobs_received(), 0); 317 + 318 + metrics.inc_jobs_received(); 319 + metrics.inc_jobs_received(); 320 + 321 + assert_eq!(metrics.jobs_received(), 2); 322 + } 323 + 324 + #[test] 325 + fn test_cache_hit_rate() { 326 + let metrics = Metrics::new(); 327 + 328 + // No cache operations yet 329 + assert_eq!(metrics.cache_hit_rate(), 0.0); 330 + 331 + // 3 hits, 1 miss = 75% 332 + metrics.inc_cache_hits(); 333 + metrics.inc_cache_hits(); 334 + metrics.inc_cache_hits(); 335 + metrics.inc_cache_misses(); 336 + 337 + assert_eq!(metrics.cache_hit_rate(), 75.0); 338 + } 339 + 340 + #[test] 341 + fn test_metrics_snapshot() { 342 + let metrics = Metrics::new(); 343 + 344 + metrics.inc_jobs_received(); 345 + metrics.inc_matches_found(); 346 + 347 + let snapshot = metrics.snapshot(); 348 + 349 + assert_eq!(snapshot.jobs_received, 1); 350 + assert_eq!(snapshot.matches_found, 1); 351 + } 352 + }
+146
src/moderation/README.md
··· 1 + # Moderation Module 2 + 3 + ## Purpose 4 + Interacts with Bluesky's Ozone moderation API to take actions (label, report, takedown) on posts and accounts. 5 + 6 + ## Key Components 7 + 8 + ### `post.rs` 9 + Moderation actions on posts: 10 + - **`label_post()`** - Apply label to post (e.g., "spam", "csam") 11 + - **`unlabel_post()`** - Remove label from post 12 + - **`report_post()`** - Report post to moderators 13 + - **`takedown_post()`** - Takedown/remove post 14 + 15 + ### `account.rs` 16 + Moderation actions on accounts: 17 + - **`label_account()`** - Apply label to account 18 + - **`unlabel_account()`** - Remove label from account 19 + - **`report_account()`** - Report account to moderators 20 + - **`takedown_account()`** - Takedown/suspend account 21 + 22 + ### `claims.rs` 23 + Deduplication to prevent duplicate reports: 24 + - **`claim_post_report()`** - Check if post already reported 25 + - **`claim_account_report()`** - Check if account already reported 26 + - Uses Redis to track `post_uri:label` pairs 27 + - Returns `true` if claim successful (first time), `false` if duplicate 28 + 29 + ### `rate_limiter.rs` 30 + Global rate limiting across all workers: 31 + - **`RateLimiter`** - Thread-safe rate limiter using governor crate 32 + - **`new(rate_limit_ms)`** - Create limiter (e.g., 100ms = 10 req/sec) 33 + - **`wait()`** - Block until allowed to make request 34 + - Enforces global limit across all concurrent workers 35 + 36 + ## API Endpoints 37 + 38 + All functions call Ozone API at `{OZONE_URL}/xrpc/tools.ozone.moderation.emitEvent`: 39 + 40 + **Event types:** 41 + - `tools.ozone.moderation.defs#modEventLabel` - Label action 42 + - `tools.ozone.moderation.defs#modEventReport` - Report action 43 + - `tools.ozone.moderation.defs#modEventTakedown` - Takedown action 44 + 45 + ## Comment Format 46 + 47 + All moderation actions include detailed comments: 48 + ``` 49 + {timestamp}: {check_comment} at https://pdsls.dev/{post_uri} with phash "{phash}" 50 + ``` 51 + 52 + Example: 53 + ``` 54 + 2025-10-24T12:34:56Z: Known spam image detected at https://pdsls.dev/at://did:plc:xyz/app.bsky.feed.post/abc with phash "e0e0e0e0e0fcfefe" 55 + ``` 56 + 57 + This provides: 58 + - **When** - ISO 8601 timestamp 59 + - **Why** - Comment from blob check rule 60 + - **Where** - pdsls.dev link to the post 61 + - **Evidence** - The phash that matched 62 + 63 + ## Rate Limiting 64 + 65 + **Problem:** 10 workers × 4 actions/match = 40 concurrent requests could overwhelm API 66 + 67 + **Solution:** Global rate limiter 68 + ```rust 69 + // Before each API call: 70 + rate_limiter.wait().await; // Blocks until safe to proceed 71 + // Make request 72 + ``` 73 + 74 + With `RATE_LIMIT_MS=100`: 75 + - Maximum 10 requests/second globally 76 + - All workers share the same limiter 77 + - Prevents rate limit errors from API 78 + 79 + ## Deduplication 80 + 81 + **Problem:** Same post might match multiple times (retries, multiple rules) 82 + 83 + **Solution:** Redis-backed claims 84 + ```rust 85 + if claim_post_report(redis, post_uri, label).await? { 86 + report_post(...).await?; // First time, do it 87 + } else { 88 + // Already reported, skip 89 + } 90 + ``` 91 + 92 + **Storage:** Redis key = `report:post:{post_uri}:{label}` with 24h TTL 93 + 94 + ## Authentication 95 + 96 + All functions require `access_token` from AgentSession: 97 + ```rust 98 + let token = agent.get_token().await?; 99 + label_post(client, &token, config, ...).await?; 100 + ``` 101 + 102 + Token is included in `Authorization: Bearer {token}` header. 103 + 104 + ## Required Headers 105 + 106 + **For Ozone API:** 107 + ``` 108 + Authorization: Bearer {access_token} 109 + atproto-proxy: {mod_did}#atproto_labeler 110 + atproto-accept-labelers: did:plc:ar7c4by46qjdydhdevvrndac;redact 111 + ``` 112 + 113 + ## Usage Flow 114 + 115 + ``` 116 + 1. Worker finds match 117 + 2. Get access token from agent 118 + 3. For each configured action: 119 + a. Check claim (dedupe) 120 + b. Wait for rate limiter 121 + c. Call moderation function 122 + d. Increment metrics 123 + ``` 124 + 125 + ## Configuration 126 + 127 + ```env 128 + MOD_DID=did:plc:xxxxx # Moderator DID 129 + OZONE_URL=https://ozone.example.com 130 + OZONE_PDS=https://bsky.social 131 + RATE_LIMIT_MS=100 # 10 req/sec max 132 + ``` 133 + 134 + ## Dependencies 135 + 136 + - `reqwest::Client` - HTTP client 137 + - `redis` - Claims storage 138 + - `governor` - Rate limiting 139 + - `chrono` - Timestamps 140 + - `serde_json` - JSON building 141 + 142 + ## Related Modules 143 + 144 + - Used by: `queue/worker.rs` - Takes actions on matches 145 + - Uses: `agent` - For access tokens 146 + - Uses: `config` - For Ozone URL and rate limit
+220
src/moderation/account.rs
··· 1 + use jacquard::client::{Agent, MemoryCredentialSession}; 2 + use jacquard_api::com_atproto::admin::RepoRef; 3 + use jacquard_api::com_atproto::moderation::ReasonType; 4 + use jacquard_api::tools_ozone::moderation::emit_event::{ 5 + EmitEvent, EmitEventEvent, EmitEventSubject, 6 + }; 7 + use jacquard_api::tools_ozone::moderation::{ 8 + ModEventLabel, ModEventReport, ModEventTakedown, ModTool, 9 + }; 10 + use jacquard_common::CowStr; 11 + use jacquard_common::types::string::Did; 12 + use jacquard_common::types::value::{Data, Object}; 13 + use jacquard_common::xrpc::{CallOptions, XrpcClient}; 14 + 15 + use jacquard_common::smol_str::SmolStr; 16 + use miette::{IntoDiagnostic, Result}; 17 + use std::collections::BTreeMap; 18 + use tracing::{debug, info}; 19 + 20 + use crate::config::Config; 21 + use crate::moderation::rate_limiter::RateLimiter; 22 + 23 + /// Label an account with a specific label via Ozone moderation API 24 + pub async fn label_account( 25 + agent: &Agent<MemoryCredentialSession>, 26 + config: &Config, 27 + rate_limiter: &RateLimiter, 28 + did: &str, 29 + label_val: &str, 30 + check_comment: &str, 31 + post_uri: &str, 32 + phash: &str, 33 + created_by: &str, 34 + ) -> Result<()> { 35 + // Wait for rate limiter before making request 36 + rate_limiter.wait().await; 37 + 38 + info!("Labeling account {} with label: {}", did, label_val); 39 + 40 + let timestamp = chrono::Utc::now().to_rfc3339(); 41 + let comment = format!("{}: {}", timestamp, check_comment); 42 + 43 + // Build mod_tool meta 44 + let mut meta_map = BTreeMap::new(); 45 + meta_map.insert( 46 + SmolStr::new("externalUrl"), 47 + format!("https://pdsls.dev/{}", post_uri).into(), 48 + ); 49 + meta_map.insert(SmolStr::new("phash"), phash.into()); 50 + 51 + // Create moderation label event using jacquard-api types 52 + let event = EmitEvent::new() 53 + .created_by(Did::new(created_by).into_diagnostic()?) 54 + .event(EmitEventEvent::ModEventLabel(Box::new( 55 + ModEventLabel::builder() 56 + .create_label_vals(vec![CowStr::from(label_val)]) 57 + .negate_label_vals(vec![]) 58 + .comment(CowStr::from(comment)) 59 + .build(), 60 + ))) 61 + .subject(EmitEventSubject::RepoRef(Box::new( 62 + RepoRef::builder() 63 + .did(Did::new(did).into_diagnostic()?) 64 + .build(), 65 + ))) 66 + .mod_tool(ModTool { 67 + name: CowStr::from("skywatch/skywatch-phash-rs"), 68 + meta: Some(Data::Object(Object::from(meta_map))), 69 + extra_data: BTreeMap::new(), 70 + }) 71 + .build(); 72 + 73 + // Build call options with proxy headers 74 + let opts = CallOptions { 75 + auth: None, 76 + atproto_proxy: Some(CowStr::from(format!( 77 + "{}#atproto_labeler", 78 + config.moderation.labeler_did 79 + ))), 80 + atproto_accept_labelers: Some(vec![CowStr::from( 81 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 82 + )]), 83 + extra_headers: vec![], 84 + }; 85 + 86 + // Send request via jacquard agent 87 + let _response = agent.send_with_opts(event, opts).await.into_diagnostic()?; 88 + 89 + debug!("Successfully labeled account: {}", did); 90 + 91 + Ok(()) 92 + } 93 + 94 + /// Report an account to ozone moderation 95 + pub async fn report_account( 96 + agent: &Agent<MemoryCredentialSession>, 97 + config: &Config, 98 + rate_limiter: &RateLimiter, 99 + did: &str, 100 + reason: ReasonType<'static>, 101 + check_comment: &str, 102 + post_uri: &str, 103 + phash: &str, 104 + created_by: &str, 105 + ) -> Result<()> { 106 + // Wait for rate limiter before making request 107 + rate_limiter.wait().await; 108 + 109 + info!("Reporting account {} to ozone: {:?}", did, reason); 110 + 111 + let timestamp = chrono::Utc::now().to_rfc3339(); 112 + let comment = format!("{}: {}", timestamp, check_comment); 113 + 114 + // Build mod_tool meta 115 + let mut meta_map = BTreeMap::new(); 116 + meta_map.insert( 117 + SmolStr::new("externalUrl"), 118 + format!("https://pdsls.dev/{}", post_uri).into(), 119 + ); 120 + meta_map.insert(SmolStr::new("phash"), phash.into()); 121 + 122 + // Create moderation report event using jacquard-api types 123 + let event = EmitEvent::new() 124 + .created_by(Did::new(created_by).into_diagnostic()?) 125 + .event(EmitEventEvent::ModEventReport(Box::new( 126 + ModEventReport::builder() 127 + .report_type(reason) 128 + .comment(CowStr::from(comment)) 129 + .build(), 130 + ))) 131 + .subject(EmitEventSubject::RepoRef(Box::new( 132 + RepoRef::builder() 133 + .did(Did::new(did).into_diagnostic()?) 134 + .build(), 135 + ))) 136 + .subject_blob_cids(vec![]) 137 + .mod_tool(ModTool { 138 + name: CowStr::from("skywatch/skywatch-phash-rs"), 139 + meta: Some(Data::Object(Object::from(meta_map))), 140 + extra_data: BTreeMap::new(), 141 + }) 142 + .build(); 143 + 144 + // Build call options with proxy headers 145 + let opts = CallOptions { 146 + auth: None, 147 + atproto_proxy: Some(CowStr::from(format!( 148 + "{}#atproto_labeler", 149 + config.moderation.labeler_did 150 + ))), 151 + atproto_accept_labelers: Some(vec![CowStr::from( 152 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 153 + )]), 154 + extra_headers: vec![], 155 + }; 156 + 157 + // Send request via jacquard agent 158 + let _response = agent.send_with_opts(event, opts).await.into_diagnostic()?; 159 + 160 + debug!("Successfully reported account: {}", did); 161 + 162 + Ok(()) 163 + } 164 + 165 + /// Takedown an account via Ozone moderation API 166 + pub async fn takedown_account( 167 + agent: &Agent<MemoryCredentialSession>, 168 + config: &Config, 169 + rate_limiter: &RateLimiter, 170 + did: &str, 171 + comment: &str, 172 + created_by: &str, 173 + ) -> Result<()> { 174 + // Wait for rate limiter before making request 175 + rate_limiter.wait().await; 176 + 177 + info!("Taking down account: {}", did); 178 + 179 + // Create moderation takedown event using jacquard-api types 180 + let event = EmitEvent::new() 181 + .created_by(Did::new(created_by).into_diagnostic()?) 182 + .event(EmitEventEvent::ModEventTakedown(Box::new( 183 + ModEventTakedown { 184 + comment: Some(CowStr::from(comment)), 185 + ..Default::default() 186 + }, 187 + ))) 188 + .subject(EmitEventSubject::RepoRef(Box::new( 189 + RepoRef::builder() 190 + .did(Did::new(did).into_diagnostic()?) 191 + .build(), 192 + ))) 193 + .build(); 194 + 195 + // Build call options with proxy headers 196 + let opts = CallOptions { 197 + auth: None, 198 + atproto_proxy: Some(CowStr::from(format!( 199 + "{}#atproto_labeler", 200 + config.moderation.labeler_did 201 + ))), 202 + atproto_accept_labelers: Some(vec![CowStr::from( 203 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 204 + )]), 205 + extra_headers: vec![], 206 + }; 207 + 208 + // Send request via jacquard agent 209 + let _response = agent.send_with_opts(event, opts).await.into_diagnostic()?; 210 + 211 + debug!("Successfully took down account: {}", did); 212 + 213 + Ok(()) 214 + } 215 + 216 + #[cfg(test)] 217 + mod tests { 218 + // Note: These are integration tests that require a real client and auth 219 + // Unit tests for account operations would require mocking the HTTP client 220 + }
+147
src/moderation/claims.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use redis::AsyncCommands; 3 + use tracing::debug; 4 + 5 + /// Redis key prefix for moderation claims 6 + const CLAIMS_PREFIX: &str = "mod:claims"; 7 + 8 + /// Redis key prefix for label claims 9 + const LABEL_PREFIX: &str = "mod:labels"; 10 + 11 + /// Default TTL for claims (24 hours) 12 + const DEFAULT_CLAIM_TTL: u64 = 86400; 13 + 14 + /// Check if a moderation action has already been claimed 15 + pub async fn has_claim( 16 + redis: &mut redis::aio::MultiplexedConnection, 17 + action_type: &str, 18 + subject: &str, 19 + label: &str, 20 + ) -> Result<bool> { 21 + let key = format!("{}:{}:{}:{}", CLAIMS_PREFIX, action_type, subject, label); 22 + let exists: bool = redis.exists(&key).await.into_diagnostic()?; 23 + Ok(exists) 24 + } 25 + 26 + /// Create a claim for a moderation action 27 + pub async fn create_claim( 28 + redis: &mut redis::aio::MultiplexedConnection, 29 + action_type: &str, 30 + subject: &str, 31 + label: &str, 32 + ttl: Option<u64>, 33 + ) -> Result<()> { 34 + let key = format!("{}:{}:{}:{}", CLAIMS_PREFIX, action_type, subject, label); 35 + let ttl_seconds = ttl.unwrap_or(DEFAULT_CLAIM_TTL); 36 + 37 + // Set with expiration 38 + let _: () = redis 39 + .set_ex(&key, "1", ttl_seconds) 40 + .await 41 + .into_diagnostic()?; 42 + 43 + debug!( 44 + "Created claim: action={}, subject={}, label={}, ttl={}s", 45 + action_type, subject, label, ttl_seconds 46 + ); 47 + 48 + Ok(()) 49 + } 50 + 51 + /// Remove a claim for a moderation action 52 + pub async fn remove_claim( 53 + redis: &mut redis::aio::MultiplexedConnection, 54 + action_type: &str, 55 + subject: &str, 56 + label: &str, 57 + ) -> Result<()> { 58 + let key = format!("{}:{}:{}:{}", CLAIMS_PREFIX, action_type, subject, label); 59 + let _: () = redis.del(&key).await.into_diagnostic()?; 60 + 61 + debug!( 62 + "Removed claim: action={}, subject={}, label={}", 63 + action_type, subject, label 64 + ); 65 + 66 + Ok(()) 67 + } 68 + 69 + /// Check if a label has already been applied 70 + pub async fn has_label( 71 + redis: &mut redis::aio::MultiplexedConnection, 72 + subject: &str, 73 + label: &str, 74 + ) -> Result<bool> { 75 + let key = format!("{}:{}:{}", LABEL_PREFIX, subject, label); 76 + let exists: bool = redis.exists(&key).await.into_diagnostic()?; 77 + Ok(exists) 78 + } 79 + 80 + /// Record that a label has been applied 81 + pub async fn set_label( 82 + redis: &mut redis::aio::MultiplexedConnection, 83 + subject: &str, 84 + label: &str, 85 + ttl: Option<u64>, 86 + ) -> Result<()> { 87 + let key = format!("{}:{}:{}", LABEL_PREFIX, subject, label); 88 + let ttl_seconds = ttl.unwrap_or(DEFAULT_CLAIM_TTL); 89 + 90 + let _: () = redis 91 + .set_ex(&key, "1", ttl_seconds) 92 + .await 93 + .into_diagnostic()?; 94 + 95 + debug!( 96 + "Set label: subject={}, label={}, ttl={}s", 97 + subject, label, ttl_seconds 98 + ); 99 + 100 + Ok(()) 101 + } 102 + 103 + /// Remove a label record 104 + pub async fn remove_label( 105 + redis: &mut redis::aio::MultiplexedConnection, 106 + subject: &str, 107 + label: &str, 108 + ) -> Result<()> { 109 + let key = format!("{}:{}:{}", LABEL_PREFIX, subject, label); 110 + let _: () = redis.del(&key).await.into_diagnostic()?; 111 + 112 + debug!("Removed label: subject={}, label={}", subject, label); 113 + 114 + Ok(()) 115 + } 116 + 117 + /// Claim a post report action (returns true if claimed successfully, false if already claimed) 118 + pub async fn claim_post_report( 119 + redis: &mut redis::aio::MultiplexedConnection, 120 + post_uri: &str, 121 + label: &str, 122 + ) -> Result<bool> { 123 + if has_claim(redis, "report_post", post_uri, label).await? { 124 + return Ok(false); 125 + } 126 + create_claim(redis, "report_post", post_uri, label, None).await?; 127 + Ok(true) 128 + } 129 + 130 + /// Claim an account report action (returns true if claimed successfully, false if already claimed) 131 + pub async fn claim_account_report( 132 + redis: &mut redis::aio::MultiplexedConnection, 133 + did: &str, 134 + label: &str, 135 + ) -> Result<bool> { 136 + if has_claim(redis, "report_account", did, label).await? { 137 + return Ok(false); 138 + } 139 + create_claim(redis, "report_account", did, label, None).await?; 140 + Ok(true) 141 + } 142 + 143 + #[cfg(test)] 144 + mod tests { 145 + // Note: These are integration tests that require a running Redis instance 146 + // Run with: cargo test --test moderation_claims -- --ignored 147 + }
+4
src/moderation/mod.rs
··· 1 + pub mod account; 2 + pub mod claims; 3 + pub mod post; 4 + pub mod rate_limiter;
+276
src/moderation/post.rs
··· 1 + use jacquard::client::{Agent, MemoryCredentialSession}; 2 + use jacquard_api::com_atproto::admin::RepoRef; 3 + use jacquard_api::com_atproto::moderation::ReasonType; 4 + use jacquard_api::com_atproto::repo::strong_ref::StrongRef; 5 + use jacquard_api::tools_ozone::moderation::emit_event::{ 6 + EmitEvent, EmitEventEvent, EmitEventSubject, 7 + }; 8 + use jacquard_api::tools_ozone::moderation::{ 9 + ModEventLabel, ModEventReport, ModEventTakedown, ModTool, 10 + }; 11 + use jacquard_common::CowStr; 12 + use jacquard_common::types::string::{AtUri, Cid, Did}; 13 + use jacquard_common::types::value::{Data, Object}; 14 + use jacquard_common::xrpc::{CallOptions, XrpcClient}; 15 + use miette::{IntoDiagnostic, Result}; 16 + use tracing::{debug, info}; 17 + 18 + use jacquard_common::smol_str::SmolStr; 19 + use std::collections::BTreeMap; 20 + 21 + use crate::config::Config; 22 + use crate::moderation::rate_limiter::RateLimiter; 23 + 24 + /// Label a post with a specific label via Ozone moderation API 25 + pub async fn label_post( 26 + agent: &Agent<MemoryCredentialSession>, 27 + config: &Config, 28 + rate_limiter: &RateLimiter, 29 + post_uri: &str, 30 + post_cid: &str, 31 + label_val: &str, 32 + check_comment: &str, 33 + phash: &str, 34 + created_by: &str, 35 + ) -> Result<()> { 36 + // Wait for rate limiter before making request 37 + rate_limiter.wait().await; 38 + 39 + info!("Labeling post {} with label: {}", post_uri, label_val); 40 + 41 + let timestamp = chrono::Utc::now().to_rfc3339(); 42 + let comment = format!("{}: {}", timestamp, check_comment); 43 + 44 + // Build mod_tool meta 45 + let mut meta_map = BTreeMap::new(); 46 + meta_map.insert( 47 + SmolStr::new("externalUrl"), 48 + format!("https://pdsls.dev/{}", post_uri).into(), 49 + ); 50 + meta_map.insert(SmolStr::new("phash"), phash.into()); 51 + 52 + // Create moderation label event using jacquard-api types 53 + let event = EmitEvent::new() 54 + .created_by(Did::new(created_by).into_diagnostic()?) 55 + .event(EmitEventEvent::ModEventLabel(Box::new( 56 + ModEventLabel::builder() 57 + .create_label_vals(vec![CowStr::from(label_val)]) 58 + .negate_label_vals(vec![]) 59 + .comment(CowStr::from(comment)) 60 + .build(), 61 + ))) 62 + .subject(EmitEventSubject::StrongRef(Box::new( 63 + StrongRef::builder() 64 + .uri(AtUri::new(post_uri).into_diagnostic()?) 65 + .cid(Cid::str(post_cid)) 66 + .build(), 67 + ))) 68 + .mod_tool(ModTool { 69 + name: CowStr::from("skywatch/skywatch-phash-rs"), 70 + meta: Some(Data::Object(Object::from(meta_map))), 71 + extra_data: BTreeMap::new(), 72 + }) 73 + .build(); 74 + 75 + // Build call options with proxy headers 76 + let opts = CallOptions { 77 + auth: None, // Agent handles auth automatically 78 + atproto_proxy: Some(CowStr::from(format!( 79 + "{}#atproto_labeler", 80 + config.moderation.labeler_did 81 + ))), 82 + atproto_accept_labelers: Some(vec![CowStr::from( 83 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 84 + )]), 85 + extra_headers: vec![], 86 + }; 87 + 88 + // Send request via jacquard agent 89 + let _response = agent.send_with_opts(event, opts).await.into_diagnostic()?; 90 + 91 + debug!("Successfully labeled post: {}", post_uri); 92 + 93 + Ok(()) 94 + } 95 + 96 + /// Report a post to ozone moderation 97 + pub async fn report_post( 98 + agent: &Agent<MemoryCredentialSession>, 99 + config: &Config, 100 + rate_limiter: &RateLimiter, 101 + post_uri: &str, 102 + _post_cid: &str, 103 + reason: ReasonType<'static>, 104 + check_comment: &str, 105 + phash: &str, 106 + created_by: &str, 107 + ) -> Result<()> { 108 + // Wait for rate limiter before making request 109 + rate_limiter.wait().await; 110 + 111 + info!("Reporting post {} to ozone: {:?}", post_uri, reason); 112 + 113 + let timestamp = chrono::Utc::now().to_rfc3339(); 114 + let comment = format!("{}: {}", timestamp, check_comment); 115 + 116 + // Extract DID from URI 117 + let did_str = extract_did_from_uri(post_uri)?; 118 + 119 + // Build mod_tool meta 120 + let mut meta_map = BTreeMap::new(); 121 + meta_map.insert( 122 + SmolStr::new("externalUrl"), 123 + format!("https://pdsls.dev/{}", post_uri).into(), 124 + ); 125 + meta_map.insert(SmolStr::new("phash"), phash.into()); 126 + 127 + // Create moderation report event using jacquard-api types 128 + let event = EmitEvent::new() 129 + .created_by(Did::new(created_by).into_diagnostic()?) 130 + .event(EmitEventEvent::ModEventReport(Box::new( 131 + ModEventReport::builder() 132 + .report_type(reason) 133 + .comment(CowStr::from(comment)) 134 + .build(), 135 + ))) 136 + .subject(EmitEventSubject::RepoRef(Box::new( 137 + RepoRef::builder() 138 + .did(Did::new(&did_str).into_diagnostic()?) 139 + .build(), 140 + ))) 141 + .subject_blob_cids(vec![]) 142 + .mod_tool(ModTool { 143 + name: CowStr::from("skywatch/skywatch-phash-rs"), 144 + meta: Some(Data::Object(Object::from(meta_map))), 145 + extra_data: BTreeMap::new(), 146 + }) 147 + .build(); 148 + 149 + // Build call options with proxy headers 150 + let opts = CallOptions { 151 + auth: None, 152 + atproto_proxy: Some(CowStr::from(format!( 153 + "{}#atproto_labeler", 154 + config.moderation.labeler_did 155 + ))), 156 + atproto_accept_labelers: Some(vec![CowStr::from( 157 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 158 + )]), 159 + extra_headers: vec![], 160 + }; 161 + 162 + // Send request via jacquard agent 163 + let _response = agent.send_with_opts(event, opts).await.into_diagnostic()?; 164 + 165 + debug!("Successfully reported post: {}", post_uri); 166 + 167 + Ok(()) 168 + } 169 + 170 + /// Takedown a post via Ozone moderation API 171 + pub async fn takedown_post( 172 + agent: &Agent<MemoryCredentialSession>, 173 + config: &Config, 174 + rate_limiter: &RateLimiter, 175 + post_uri: &str, 176 + post_cid: &str, 177 + comment: &str, 178 + created_by: &str, 179 + ) -> Result<()> { 180 + // Wait for rate limiter before making request 181 + rate_limiter.wait().await; 182 + 183 + info!("Taking down post: {}", post_uri); 184 + 185 + // Create moderation takedown event using jacquard-api types 186 + let event = EmitEvent::new() 187 + .created_by(Did::new(created_by).into_diagnostic()?) 188 + .event(EmitEventEvent::ModEventTakedown(Box::new( 189 + ModEventTakedown { 190 + comment: Some(CowStr::from(comment)), 191 + ..Default::default() 192 + }, 193 + ))) 194 + .subject(EmitEventSubject::StrongRef(Box::new( 195 + StrongRef::builder() 196 + .uri(AtUri::new(post_uri).into_diagnostic()?) 197 + .cid(Cid::str(post_cid)) 198 + .build(), 199 + ))) 200 + .build(); 201 + 202 + // Build call options with proxy headers 203 + let opts = CallOptions { 204 + auth: None, 205 + atproto_proxy: Some(CowStr::from(format!( 206 + "{}#atproto_labeler", 207 + config.moderation.labeler_did 208 + ))), 209 + atproto_accept_labelers: Some(vec![CowStr::from( 210 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 211 + )]), 212 + extra_headers: vec![], 213 + }; 214 + 215 + // Send request via jacquard agent 216 + let _response = agent.send_with_opts(event, opts).await.into_diagnostic()?; 217 + 218 + debug!("Successfully took down post: {}", post_uri); 219 + 220 + Ok(()) 221 + } 222 + 223 + /// Parse an AT URI into its components 224 + /// Format: at://did:plc:xxx/app.bsky.feed.post/rkey 225 + fn parse_at_uri(uri: &str) -> Result<(String, String, String)> { 226 + let uri = uri 227 + .strip_prefix("at://") 228 + .ok_or_else(|| miette::miette!("Invalid AT URI format: missing 'at://' prefix"))?; 229 + 230 + let parts: Vec<&str> = uri.split('/').collect(); 231 + if parts.len() != 3 { 232 + return Err(miette::miette!( 233 + "Invalid AT URI format: expected 3 parts, got {}", 234 + parts.len() 235 + )); 236 + } 237 + 238 + Ok(( 239 + parts[0].to_string(), // repo (DID) 240 + parts[1].to_string(), // collection 241 + parts[2].to_string(), // rkey 242 + )) 243 + } 244 + 245 + /// Extract DID from AT URI 246 + fn extract_did_from_uri(uri: &str) -> Result<String> { 247 + let (did, _, _) = parse_at_uri(uri)?; 248 + Ok(did) 249 + } 250 + 251 + #[cfg(test)] 252 + mod tests { 253 + use super::*; 254 + 255 + #[test] 256 + fn test_parse_at_uri() { 257 + let uri = "at://did:plc:xyz123/app.bsky.feed.post/abc456"; 258 + let (repo, collection, rkey) = parse_at_uri(uri).unwrap(); 259 + assert_eq!(repo, "did:plc:xyz123"); 260 + assert_eq!(collection, "app.bsky.feed.post"); 261 + assert_eq!(rkey, "abc456"); 262 + } 263 + 264 + #[test] 265 + fn test_parse_at_uri_invalid() { 266 + let uri = "https://example.com/post/123"; 267 + assert!(parse_at_uri(uri).is_err()); 268 + } 269 + 270 + #[test] 271 + fn test_extract_did_from_uri() { 272 + let uri = "at://did:plc:xyz123/app.bsky.feed.post/abc456"; 273 + let did = extract_did_from_uri(uri).unwrap(); 274 + assert_eq!(did, "did:plc:xyz123"); 275 + } 276 + }
+91
src/moderation/rate_limiter.rs
··· 1 + use governor::{ 2 + clock::DefaultClock, 3 + state::{InMemoryState, NotKeyed}, 4 + Quota, RateLimiter as GovernorRateLimiter, 5 + }; 6 + use std::sync::Arc; 7 + use std::time::Duration; 8 + 9 + /// Thread-safe rate limiter for API requests 10 + #[derive(Clone)] 11 + pub struct RateLimiter { 12 + limiter: Arc<GovernorRateLimiter<NotKeyed, InMemoryState, DefaultClock>>, 13 + } 14 + 15 + impl RateLimiter { 16 + /// Create a new rate limiter with the given rate limit in milliseconds 17 + /// For example, rate_limit_ms = 100 means 100ms minimum between requests (10 requests per second) 18 + pub fn new(rate_limit_ms: u64) -> Self { 19 + let duration = if rate_limit_ms == 0 { 20 + Duration::from_millis(1) 21 + } else { 22 + Duration::from_millis(rate_limit_ms) 23 + }; 24 + 25 + // 1 request per rate_limit_ms duration 26 + let quota = Quota::with_period(duration).unwrap(); 27 + let limiter = GovernorRateLimiter::direct(quota); 28 + 29 + Self { 30 + limiter: Arc::new(limiter), 31 + } 32 + } 33 + 34 + /// Wait until a request can be made according to the rate limit 35 + pub async fn wait(&self) { 36 + while self.limiter.check().is_err() { 37 + tokio::time::sleep(Duration::from_millis(1)).await; 38 + } 39 + } 40 + } 41 + 42 + #[cfg(test)] 43 + mod tests { 44 + use super::*; 45 + use std::time::Instant; 46 + 47 + #[tokio::test] 48 + async fn test_rate_limiter() { 49 + // 100ms between requests = 10 requests per second 50 + let limiter = RateLimiter::new(100); 51 + 52 + let start = Instant::now(); 53 + 54 + // Make 3 requests 55 + for _ in 0..3 { 56 + limiter.wait().await; 57 + } 58 + 59 + let elapsed = start.elapsed(); 60 + 61 + // Should take at least 200ms (2 intervals between 3 requests) 62 + assert!(elapsed >= Duration::from_millis(180)); 63 + } 64 + 65 + #[tokio::test] 66 + async fn test_rate_limiter_concurrent() { 67 + // 100ms between requests = 10 requests per second 68 + let limiter = RateLimiter::new(100); 69 + 70 + let start = Instant::now(); 71 + 72 + // Spawn 5 concurrent tasks 73 + let mut handles = vec![]; 74 + for _ in 0..5 { 75 + let limiter_clone = limiter.clone(); 76 + handles.push(tokio::spawn(async move { 77 + limiter_clone.wait().await; 78 + })); 79 + } 80 + 81 + // Wait for all to complete 82 + for handle in handles { 83 + handle.await.unwrap(); 84 + } 85 + 86 + let elapsed = start.elapsed(); 87 + 88 + // Should take at least 400ms (4 intervals between 5 requests) 89 + assert!(elapsed >= Duration::from_millis(380)); 90 + } 91 + }
+154
src/processor/README.md
··· 1 + # Processor Module 2 + 3 + ## Purpose 4 + Download images, compute perceptual hashes, and match against known bad image hashes. 5 + 6 + ## Key Components 7 + 8 + ### `phash.rs` 9 + Perceptual hash computation: 10 + - **`compute_phash(image_bytes)`** - Calculate 64-bit aHash from image 11 + - Uses `image_hasher` crate with aHash (average hash) algorithm 12 + - Returns 16-character hex string (e.g., "e0e0e0e0e0fcfefe") 13 + - Hash size: 8×8 = 64 bits 14 + - **`hamming_distance(hash1, hash2)`** - Compare two phashes 15 + - Counts differing bits between two hashes 16 + - Returns u32 (0 = identical, 64 = completely different) 17 + - Used to find "similar" images (threshold typically 3-5) 18 + 19 + ### `matcher.rs` 20 + Image download and rule matching: 21 + - **`download_blob(client, config, did, cid)`** - Download image from CDN/PDS 22 + - Tries CDN first: `https://cdn.bsky.app/img/feed_fullsize/plain/{did}/{cid}@{format}` 23 + - Attempts common formats: jpeg, png, webp 24 + - Falls back to PDS: `com.atproto.sync.getBlob` 25 + - Returns raw image bytes 26 + - **`load_blob_checks(path)`** - Load rules from JSON file 27 + - Reads `rules/blobs.json` 28 + - Deserializes into `Vec<BlobCheck>` 29 + - **`match_phash(phash, checks, did, threshold)`** - Check if phash matches rules 30 + - Compares computed phash against all rules 31 + - Checks hamming distance against threshold 32 + - Skips DIDs in `ignore_did` list 33 + - Returns `Some(MatchResult)` on match, `None` otherwise 34 + 35 + ## Perceptual Hashing (aHash) 36 + 37 + **What is aHash?** 38 + - Algorithm: Average Hash 39 + - Process: 40 + 1. Resize image to 8×8 pixels 41 + 2. Convert to grayscale 42 + 3. Calculate average brightness 43 + 4. For each pixel: bit = 1 if brighter than average, 0 if darker 44 + 5. Concatenate 64 bits into hash 45 + 46 + **Why aHash?** 47 + - Fast to compute (~50ms) 48 + - Robust to resizing, cropping, minor edits 49 + - Not robust to rotation, color changes (by design) 50 + - Good for finding exact or near-exact reposts 51 + 52 + **Example:** 53 + ``` 54 + Original image: 1920×1080 JPEG 55 + → Resize: 8×8 grayscale 56 + → Average: 128 57 + → Bits: [1,0,1,1,0,0,1,1, ...] 58 + → Hash: "e0e0e0e0e0fcfefe" 59 + ``` 60 + 61 + ## Hamming Distance 62 + 63 + Counts bit differences between two hashes: 64 + ``` 65 + Hash A: 1010 66 + Hash B: 1100 67 + XOR: 0110 (2 bits differ) 68 + Hamming: 2 69 + ``` 70 + 71 + **Thresholds:** 72 + - 0 - Exact match (same image) 73 + - 1-3 - Very similar (minor edits, recompression) 74 + - 4-8 - Similar (cropping, watermarks) 75 + - 9+ - Different images 76 + 77 + ## CDN-First Download Strategy 78 + 79 + **Why CDN first?** 80 + - Faster (geographically distributed) 81 + - Cached (reduces PDS load) 82 + - Free (no quota concerns) 83 + 84 + **Why try multiple formats?** 85 + - CDN URL requires format extension (@jpeg, @png, @webp) 86 + - Blob CID doesn't tell us the format 87 + - Try common formats until one works 88 + 89 + **Flow:** 90 + ``` 91 + 1. Try: cdn.bsky.app/.../cid@jpeg 92 + → 404 93 + 2. Try: cdn.bsky.app/.../cid@png 94 + → 200 OK (download) 95 + 3. Return bytes 96 + ``` 97 + 98 + **Fallback to PDS:** 99 + - If all CDN attempts fail 100 + - Uses getBlob API (format-agnostic) 101 + - Slower but always works 102 + 103 + ## Rule Matching Logic 104 + 105 + ```rust 106 + for check in blob_checks { 107 + // Skip ignored DIDs 108 + if check.ignore_did.contains(did) { 109 + continue; 110 + } 111 + 112 + // Check each known bad hash 113 + for bad_hash in check.phashes { 114 + let distance = hamming_distance(computed_hash, bad_hash); 115 + let threshold = check.hamming_threshold.unwrap_or(default); 116 + 117 + if distance <= threshold { 118 + return Some(MatchResult { 119 + matched_check: check, 120 + matched_phash: bad_hash, 121 + hamming_distance: distance, 122 + phash: computed_hash, 123 + }); 124 + } 125 + } 126 + } 127 + ``` 128 + 129 + ## Performance 130 + 131 + **Typical timings:** 132 + - Download (cache hit): ~1ms 133 + - Download (CDN): ~100-200ms 134 + - Download (PDS fallback): ~200-500ms 135 + - Compute phash: ~20-50ms 136 + - Match against 100 rules: ~0.1ms 137 + 138 + **Optimization:** 139 + - Cache phashes by CID (avoid recomputation) 140 + - CDN preferred over PDS (faster) 141 + - Hamming distance is bitwise XOR (very fast) 142 + 143 + ## Dependencies 144 + 145 + - `reqwest::Client` - HTTP downloads 146 + - `image` - Image loading/decoding 147 + - `image_hasher` - Phash computation 148 + - `serde_json` - Rule parsing 149 + 150 + ## Related Modules 151 + 152 + - Used by: `queue/worker.rs` - Downloads and matches blobs 153 + - Returns: `types::MatchResult` - What matched and why 154 + - Uses: `types::BlobCheck` - Rule definitions
src/processor/fetcher.rs

This is a binary file and will not be displayed.

+295
src/processor/matcher.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use reqwest::Client; 3 + use std::path::Path; 4 + use tracing::{debug, info, warn}; 5 + 6 + use crate::config::Config; 7 + use crate::processor::phash; 8 + use crate::types::{BlobCheck, BlobReference, ImageJob, MatchResult}; 9 + 10 + /// Load blob checks from a JSON file 11 + pub async fn load_blob_checks(path: &Path) -> Result<Vec<BlobCheck>> { 12 + let contents = tokio::fs::read_to_string(path).await.into_diagnostic()?; 13 + let checks: Vec<BlobCheck> = serde_json::from_str(&contents).into_diagnostic()?; 14 + info!("Loaded {} blob checks from {:?}", checks.len(), path); 15 + Ok(checks) 16 + } 17 + 18 + /// Download a blob from the Bluesky CDN, falling back to PDS if necessary 19 + pub async fn download_blob( 20 + client: &Client, 21 + config: &Config, 22 + did: &str, 23 + cid: &str, 24 + ) -> Result<Vec<u8>> { 25 + // Try CDN first - attempt common image formats 26 + for format in ["jpeg", "png", "webp"] { 27 + let cdn_url = format!( 28 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 29 + did, cid, format 30 + ); 31 + 32 + debug!("Trying CDN download: {}", cdn_url); 33 + 34 + match client.get(&cdn_url).send().await { 35 + Ok(response) if response.status().is_success() => { 36 + debug!("Successfully downloaded from CDN: did={}, cid={}", did, cid); 37 + let bytes = response.bytes().await.into_diagnostic()?; 38 + return Ok(bytes.to_vec()); 39 + } 40 + Ok(response) => { 41 + debug!("CDN returned status {}, trying next format", response.status()); 42 + } 43 + Err(e) => { 44 + debug!("CDN request failed: {}, trying next format", e); 45 + } 46 + } 47 + } 48 + 49 + // Fall back to PDS if CDN fails 50 + warn!("CDN failed for did={}, cid={}, falling back to PDS", did, cid); 51 + 52 + let pds_url = format!( 53 + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", 54 + config.pds.endpoint, did, cid 55 + ); 56 + 57 + debug!("Downloading from PDS: {}", pds_url); 58 + 59 + let response = client 60 + .get(&pds_url) 61 + .send() 62 + .await 63 + .into_diagnostic()? 64 + .error_for_status() 65 + .into_diagnostic()?; 66 + 67 + let bytes = response.bytes().await.into_diagnostic()?; 68 + Ok(bytes.to_vec()) 69 + } 70 + 71 + /// Match a computed phash against blob checks 72 + pub fn match_phash( 73 + phash: &str, 74 + blob_checks: &[BlobCheck], 75 + did: &str, 76 + default_threshold: u32, 77 + ) -> Option<MatchResult> { 78 + for check in blob_checks { 79 + // Check if DID is in ignore list 80 + if let Some(ignore_list) = &check.ignore_did { 81 + if ignore_list.contains(&did.to_string()) { 82 + debug!("Skipping check '{}' for ignored DID: {}", check.label, did); 83 + continue; 84 + } 85 + } 86 + 87 + let threshold = check.hamming_threshold.unwrap_or(default_threshold); 88 + 89 + // Check each phash in the check 90 + for check_phash in &check.phashes { 91 + match phash::hamming_distance(phash, check_phash) { 92 + Ok(distance) => { 93 + if distance <= threshold { 94 + info!( 95 + "Match found! label={}, distance={}, threshold={}", 96 + check.label, distance, threshold 97 + ); 98 + return Some(MatchResult { 99 + phash: phash.to_string(), 100 + matched_check: check.clone(), 101 + matched_phash: check_phash.clone(), 102 + hamming_distance: distance, 103 + }); 104 + } 105 + } 106 + Err(e) => { 107 + warn!("Failed to compute hamming distance: {}", e); 108 + continue; 109 + } 110 + } 111 + } 112 + } 113 + 114 + None 115 + } 116 + 117 + /// Process a single blob reference 118 + pub async fn process_blob( 119 + client: &Client, 120 + config: &Config, 121 + blob_checks: &[BlobCheck], 122 + did: &str, 123 + blob: &BlobReference, 124 + ) -> Result<Option<MatchResult>> { 125 + // Download the blob 126 + let image_bytes = download_blob(client, config, did, &blob.cid).await?; 127 + 128 + // Compute phash 129 + let phash = phash::compute_phash(&image_bytes)?; 130 + debug!("Computed phash for blob {}: {}", blob.cid, phash); 131 + 132 + // Match against checks 133 + let match_result = match_phash(&phash, blob_checks, did, config.phash.default_hamming_threshold); 134 + 135 + Ok(match_result) 136 + } 137 + 138 + /// Process an image job - check all blobs and return matches 139 + pub async fn process_image_job( 140 + client: &Client, 141 + config: &Config, 142 + blob_checks: &[BlobCheck], 143 + job: &ImageJob, 144 + ) -> Result<Vec<MatchResult>> { 145 + info!( 146 + "Processing job: post={}, blobs={}", 147 + job.post_uri, 148 + job.blobs.len() 149 + ); 150 + 151 + let mut matches = Vec::new(); 152 + 153 + for blob in &job.blobs { 154 + match process_blob(client, config, blob_checks, &job.post_did, blob).await { 155 + Ok(Some(result)) => { 156 + matches.push(result); 157 + } 158 + Ok(None) => { 159 + debug!("No match for blob: {}", blob.cid); 160 + } 161 + Err(e) => { 162 + warn!("Error processing blob {}: {}", blob.cid, e); 163 + // Continue processing other blobs 164 + } 165 + } 166 + } 167 + 168 + if !matches.is_empty() { 169 + info!( 170 + "Found {} match(es) for post: {}", 171 + matches.len(), 172 + job.post_uri 173 + ); 174 + } 175 + 176 + Ok(matches) 177 + } 178 + 179 + #[cfg(test)] 180 + mod tests { 181 + use super::*; 182 + 183 + #[test] 184 + fn test_match_phash_exact() { 185 + let checks = vec![BlobCheck { 186 + phashes: vec!["deadbeefdeadbeef".to_string()], 187 + label: "test-label".to_string(), 188 + comment: "Test".to_string(), 189 + report_acct: false, 190 + label_acct: false, 191 + report_post: true, 192 + to_label: true, 193 + takedown_post: false, 194 + takedown_acct: false, 195 + hamming_threshold: Some(3), 196 + description: None, 197 + ignore_did: None, 198 + }]; 199 + 200 + let result = match_phash("deadbeefdeadbeef", &checks, "did:plc:test", 3); 201 + assert!(result.is_some()); 202 + assert_eq!(result.unwrap().hamming_distance, 0); 203 + } 204 + 205 + #[test] 206 + fn test_match_phash_within_threshold() { 207 + let checks = vec![BlobCheck { 208 + phashes: vec!["deadbeefdeadbeef".to_string()], 209 + label: "test-label".to_string(), 210 + comment: "Test".to_string(), 211 + report_acct: false, 212 + label_acct: false, 213 + report_post: true, 214 + to_label: true, 215 + takedown_post: false, 216 + takedown_acct: false, 217 + hamming_threshold: Some(3), 218 + description: None, 219 + ignore_did: None, 220 + }]; 221 + 222 + // deadbeefdeadbeef vs deadbeefdeadbeee = 1 bit difference in last nibble 223 + let result = match_phash("deadbeefdeadbeee", &checks, "did:plc:test", 3); 224 + assert!(result.is_some()); 225 + assert_eq!(result.unwrap().hamming_distance, 1); 226 + } 227 + 228 + #[test] 229 + fn test_match_phash_exceeds_threshold() { 230 + let checks = vec![BlobCheck { 231 + phashes: vec!["deadbeefdeadbeef".to_string()], 232 + label: "test-label".to_string(), 233 + comment: "Test".to_string(), 234 + report_acct: false, 235 + label_acct: false, 236 + report_post: true, 237 + to_label: true, 238 + takedown_post: false, 239 + takedown_acct: false, 240 + hamming_threshold: Some(1), 241 + description: None, 242 + ignore_did: None, 243 + }]; 244 + 245 + // More than 1 bit difference 246 + let result = match_phash("deadbeefdeadbee0", &checks, "did:plc:test", 1); 247 + assert!(result.is_none()); 248 + } 249 + 250 + #[test] 251 + fn test_match_phash_ignored_did() { 252 + let checks = vec![BlobCheck { 253 + phashes: vec!["deadbeefdeadbeef".to_string()], 254 + label: "test-label".to_string(), 255 + comment: "Test".to_string(), 256 + report_acct: false, 257 + label_acct: false, 258 + report_post: true, 259 + to_label: true, 260 + takedown_post: false, 261 + takedown_acct: false, 262 + hamming_threshold: Some(3), 263 + description: None, 264 + ignore_did: Some(vec!["did:plc:ignored".to_string()]), 265 + }]; 266 + 267 + let result = match_phash("deadbeefdeadbeef", &checks, "did:plc:ignored", 3); 268 + assert!(result.is_none()); 269 + } 270 + 271 + #[tokio::test] 272 + async fn test_load_real_rules() { 273 + let path = std::path::Path::new("rules/blobs.json"); 274 + if !path.exists() { 275 + // Skip test if rules file doesn't exist 276 + return; 277 + } 278 + 279 + let result = load_blob_checks(path).await; 280 + assert!(result.is_ok()); 281 + 282 + let checks = result.unwrap(); 283 + assert!(!checks.is_empty()); 284 + 285 + // Verify first check has expected fields 286 + let first = &checks[0]; 287 + assert!(!first.phashes.is_empty()); 288 + assert!(!first.label.is_empty()); 289 + 290 + // Check that ignoreDID alias works 291 + if let Some(ignore_list) = &first.ignore_did { 292 + assert!(!ignore_list.is_empty()); 293 + } 294 + } 295 + }
+5
src/processor/mod.rs
··· 1 + pub mod matcher; 2 + pub mod phash; 3 + 4 + // Re-export for convenience 5 + pub use phash::{compute_phash, compute_phash_from_image, hamming_distance, PhashError};
+165
src/processor/phash.rs
··· 1 + use image::DynamicImage; 2 + use image_hasher::{HashAlg, HasherConfig, ImageHash}; 3 + use miette::{Diagnostic, IntoDiagnostic, Result}; 4 + use thiserror::Error; 5 + 6 + #[derive(Debug, Error, Diagnostic)] 7 + pub enum PhashError { 8 + #[error("Failed to decode image: {0}")] 9 + ImageDecodeError(String), 10 + 11 + #[error("Failed to compute hash: {0}")] 12 + HashComputeError(String), 13 + 14 + #[error("Invalid hash format: {0}")] 15 + InvalidHashFormat(String), 16 + } 17 + 18 + /// Compute perceptual hash for an image using average hash (aHash) algorithm 19 + /// 20 + /// This matches the TypeScript implementation: 21 + /// 1. Resize to 8x8 (64 pixels) 22 + /// 2. Convert to grayscale 23 + /// 3. Compute average pixel value 24 + /// 4. Create 64-bit binary: 1 if pixel > avg, 0 otherwise 25 + /// 5. Convert to hex string (16 chars) 26 + pub fn compute_phash(image_bytes: &[u8]) -> Result<String> { 27 + // Decode image from bytes 28 + let img = image::load_from_memory(image_bytes) 29 + .into_diagnostic() 30 + .map_err(|e| PhashError::ImageDecodeError(e.to_string()))?; 31 + 32 + compute_phash_from_image(&img) 33 + } 34 + 35 + /// Compute perceptual hash from a DynamicImage 36 + pub fn compute_phash_from_image(img: &DynamicImage) -> Result<String> { 37 + // Configure hasher with aHash (Mean) algorithm and 8x8 size 38 + let hasher = HasherConfig::new() 39 + .hash_alg(HashAlg::Mean) // average hash 40 + .hash_size(8, 8) // 64 bits 41 + .to_hasher(); 42 + 43 + // Compute hash 44 + let hash = hasher.hash_image(img); 45 + 46 + // Convert to hex string 47 + hash_to_hex(&hash) 48 + } 49 + 50 + /// Convert ImageHash to hex string format (16 chars, matching TS output) 51 + fn hash_to_hex(hash: &ImageHash) -> Result<String> { 52 + // Get hash bytes 53 + let bytes = hash.as_bytes(); 54 + 55 + // Convert to hex string 56 + let hex = bytes 57 + .iter() 58 + .map(|b| format!("{:02x}", b)) 59 + .collect::<String>(); 60 + 61 + // Ensure it's 16 characters (64 bits = 8 bytes = 16 hex chars) 62 + if hex.len() != 16 { 63 + return Err(PhashError::InvalidHashFormat(format!( 64 + "Expected 16 hex characters, got {}", 65 + hex.len() 66 + )) 67 + .into()); 68 + } 69 + 70 + Ok(hex) 71 + } 72 + 73 + /// Compute hamming distance between two phash hex strings 74 + /// 75 + /// Uses Brian Kernighan's algorithm to count set bits 76 + pub fn hamming_distance(hash1: &str, hash2: &str) -> Result<u32> { 77 + // Validate input lengths 78 + if hash1.len() != 16 || hash2.len() != 16 { 79 + return Err(PhashError::InvalidHashFormat(format!( 80 + "Hashes must be 16 hex characters, got {} and {}", 81 + hash1.len(), 82 + hash2.len() 83 + )) 84 + .into()); 85 + } 86 + 87 + // Parse hex strings to u64 88 + let a = u64::from_str_radix(hash1, 16) 89 + .into_diagnostic() 90 + .map_err(|_| PhashError::InvalidHashFormat(format!("Invalid hex string: {}", hash1)))?; 91 + 92 + let b = u64::from_str_radix(hash2, 16) 93 + .into_diagnostic() 94 + .map_err(|_| PhashError::InvalidHashFormat(format!("Invalid hex string: {}", hash2)))?; 95 + 96 + // XOR to find differing bits 97 + let xor = a ^ b; 98 + 99 + // Count set bits using Brian Kernighan's algorithm 100 + let mut count = 0u32; 101 + let mut n = xor; 102 + while n > 0 { 103 + count += 1; 104 + n &= n - 1; // clear the lowest set bit 105 + } 106 + 107 + Ok(count) 108 + } 109 + 110 + #[cfg(test)] 111 + mod tests { 112 + use super::*; 113 + 114 + #[test] 115 + fn test_hamming_distance_identical() { 116 + let hash = "e0e0e0e0e0fcfefe"; 117 + let distance = hamming_distance(hash, hash).unwrap(); 118 + assert_eq!(distance, 0); 119 + } 120 + 121 + #[test] 122 + fn test_hamming_distance_different() { 123 + let hash1 = "0000000000000000"; 124 + let hash2 = "ffffffffffffffff"; 125 + let distance = hamming_distance(hash1, hash2).unwrap(); 126 + assert_eq!(distance, 64); // all bits different 127 + } 128 + 129 + #[test] 130 + fn test_hamming_distance_one_bit() { 131 + let hash1 = "0000000000000000"; 132 + let hash2 = "0000000000000001"; 133 + let distance = hamming_distance(hash1, hash2).unwrap(); 134 + assert_eq!(distance, 1); 135 + } 136 + 137 + #[test] 138 + fn test_hamming_distance_invalid_length() { 139 + let hash1 = "e0e0e0e0e0fcfefe"; 140 + let hash2 = "short"; 141 + let result = hamming_distance(hash1, hash2); 142 + assert!(result.is_err()); 143 + } 144 + 145 + #[test] 146 + fn test_hamming_distance_invalid_hex() { 147 + let hash1 = "e0e0e0e0e0fcfefe"; 148 + let hash2 = "gggggggggggggggg"; 149 + let result = hamming_distance(hash1, hash2); 150 + assert!(result.is_err()); 151 + } 152 + 153 + #[test] 154 + fn test_phash_format() { 155 + // Create a simple test image (1x1 black pixel) 156 + let img = DynamicImage::new_luma8(1, 1); 157 + let hash = compute_phash_from_image(&img).unwrap(); 158 + 159 + // Should be 16 hex characters 160 + assert_eq!(hash.len(), 16); 161 + 162 + // Should be valid hex 163 + u64::from_str_radix(&hash, 16).unwrap(); 164 + } 165 + }
+187
src/queue/README.md
··· 1 + # Queue Module 2 + 3 + ## Purpose 4 + Redis-backed job queue with concurrent worker pool for processing image moderation jobs. 5 + 6 + ## Key Components 7 + 8 + ### `redis_queue.rs` 9 + - **`JobQueue`** - Redis-backed FIFO queue 10 + - **`push(job)`** - Add job to main queue (`queue:main`) 11 + - **`pop(timeout)`** - Block-pop next job from queue 12 + - **`retry(job)`** - Add failed job to retry queue 13 + - Uses Redis LIST operations (LPUSH/BRPOP) 14 + - **Dead letter queue** - Jobs that fail after max retries 15 + - Stored in `queue:dead_letter` 16 + - Can be inspected/reprocessed manually 17 + 18 + ### `worker.rs` 19 + Main worker pool implementation: 20 + - **`WorkerPool`** - Manages concurrent job processing 21 + - Semaphore-controlled concurrency (max N workers) 22 + - Shared rate limiter across all workers 23 + - Each worker gets own Redis connection 24 + - **`start(queue, cache, shutdown_rx)`** - Main event loop 25 + - Pops jobs from queue 26 + - Spawns worker tasks up to concurrency limit 27 + - Handles graceful shutdown 28 + - **`process_job()`** - Process single job 29 + 1. Check cache for each blob 30 + 2. Download and compute phash if needed 31 + 3. Match against rules 32 + 4. Take moderation actions on matches 33 + - **`process_job_blobs()`** - Download/compute phashes for all blobs 34 + - **`take_moderation_action()`** - Execute configured actions 35 + 36 + ## Job Flow 37 + 38 + ``` 39 + Jetstream → JobQueue.push() → Redis queue:main 40 + 41 + WorkerPool.start() 42 + 43 + Semaphore.acquire() (limit concurrency) 44 + 45 + Spawn worker task 46 + 47 + process_job() 48 + 49 + ┌──────────────┴──────────────┐ 50 + ↓ ↓ 51 + Success Failure 52 + ↓ ↓ 53 + Complete JobQueue.retry() 54 + 55 + (job.attempts < max_retries?) 56 + ↓ ↓ 57 + Yes No 58 + ↓ ↓ 59 + queue:main queue:dead_letter 60 + ``` 61 + 62 + ## Concurrency Control 63 + 64 + **Semaphore pattern:** 65 + ```rust 66 + let semaphore = Arc::new(Semaphore::new(concurrency)); 67 + 68 + loop { 69 + let permit = semaphore.acquire().await; 70 + tokio::spawn(async move { 71 + process_job(...).await; 72 + drop(permit); // Release permit when done 73 + }); 74 + } 75 + ``` 76 + 77 + **Why semaphore?** 78 + - Limits max concurrent jobs (prevents resource exhaustion) 79 + - Workers block when limit reached 80 + - Auto-released on task completion (even if panics) 81 + 82 + **Concurrency setting:** 83 + - Default: 10 workers 84 + - Configurable via `PROCESSING_CONCURRENCY` 85 + - Each worker needs: memory for image, Redis conn, HTTP conn 86 + 87 + ## Retry Logic 88 + 89 + **Retry conditions:** 90 + - Image download fails 91 + - Phash computation fails 92 + - Moderation API returns error 93 + - Any error in `process_job()` 94 + 95 + **Retry mechanism:** 96 + ```rust 97 + job.attempts += 1; 98 + if job.attempts < max_retries { 99 + queue.retry(job).await?; // Back to queue:main 100 + } else { 101 + // Move to dead letter queue 102 + } 103 + ``` 104 + 105 + **Retry delay:** 106 + - Controlled by `RETRY_DELAY_MS` (default 1000ms) 107 + - Applied when job is retried 108 + - Prevents hammering failing resources 109 + 110 + ## Worker Task Lifecycle 111 + 112 + ``` 113 + 1. Main loop pops job from queue 114 + 2. Acquire semaphore permit (blocks if at limit) 115 + 3. Clone data for worker (config, client, etc.) 116 + 4. Spawn tokio task with job 117 + 5. Worker creates own Redis connection 118 + 6. Process job (download, compute, match, act) 119 + 7. On success: increment metrics, complete 120 + 8. On failure: retry job, increment failed metrics 121 + 9. Drop permit (allows next worker to start) 122 + ``` 123 + 124 + ## Job Processing Details 125 + 126 + ### process_job_blobs() 127 + For each blob in job: 128 + 1. Check cache: `cache.get(cid)` 129 + 2. If cache hit: use cached phash 130 + 3. If cache miss: 131 + - Download blob from CDN/PDS 132 + - Compute phash 133 + - Store in cache: `cache.set(cid, phash)` 134 + 4. Match phash against rules 135 + 5. Collect all matches 136 + 137 + ### take_moderation_action() 138 + For each match: 139 + 1. Get access token from agent 140 + 2. Check configured actions (from BlobCheck): 141 + - `report_post` → `post::report_post()` 142 + - `to_label` → `post::label_post()` 143 + - `report_acct` → `account::report_account()` 144 + - `label_acct` → `account::label_account()` 145 + 3. Use claims to prevent duplicates 146 + 4. Rate limit before each API call 147 + 5. Increment metrics 148 + 149 + ## Redis Queues 150 + 151 + **Main queue:** `queue:main` 152 + - All new jobs and retries 153 + - FIFO order 154 + - Block-pop with timeout 155 + 156 + **Dead letter queue:** `queue:dead_letter` 157 + - Jobs that failed max_retries times 158 + - Preserved for manual inspection 159 + - Never auto-retried 160 + 161 + **Storage format:** JSON-serialized `ImageJob` 162 + 163 + ## Configuration 164 + 165 + ```env 166 + PROCESSING_CONCURRENCY=10 # Max concurrent workers 167 + RETRY_ATTEMPTS=3 # Max retries per job 168 + RETRY_DELAY_MS=1000 # Delay between retries 169 + REDIS_URL=redis://localhost:6379 170 + ``` 171 + 172 + ## Dependencies 173 + 174 + - `redis` - Queue storage 175 + - `tokio::sync::Semaphore` - Concurrency control 176 + - `reqwest::Client` - Image downloads 177 + - `AgentSession` - Authentication 178 + - `PhashCache` - Phash caching 179 + - `Metrics` - Performance tracking 180 + 181 + ## Related Modules 182 + 183 + - Receives from: `jetstream` - Jobs via mpsc channel 184 + - Uses: `processor` - Download and match blobs 185 + - Uses: `moderation` - Take actions on matches 186 + - Uses: `cache` - Phash caching 187 + - Uses: `agent` - API authentication
+5
src/queue/mod.rs
··· 1 + pub mod redis_queue; 2 + pub mod worker; 3 + 4 + pub use redis_queue::{JobQueue, QueueStats}; 5 + pub use worker::WorkerPool;
+160
src/queue/redis_queue.rs
··· 1 + use miette::{IntoDiagnostic, Result}; 2 + use redis::AsyncCommands; 3 + use tracing::{debug, info, warn}; 4 + 5 + use crate::config::Config; 6 + use crate::types::ImageJob; 7 + 8 + /// Redis queue names 9 + const PENDING_QUEUE: &str = "jobs:pending"; 10 + const PROCESSING_QUEUE: &str = "jobs:processing"; 11 + const DEAD_LETTER_QUEUE: &str = "jobs:dead"; 12 + 13 + /// Redis-based job queue for ImageJob processing 14 + #[derive(Clone)] 15 + pub struct JobQueue { 16 + redis: redis::aio::MultiplexedConnection, 17 + max_retries: u32, 18 + } 19 + 20 + impl JobQueue { 21 + /// Create a new job queue 22 + pub async fn new(config: &Config) -> Result<Self> { 23 + info!("Connecting to Redis for job queue: {}", config.redis.url); 24 + 25 + let client = redis::Client::open(config.redis.url.as_str()).into_diagnostic()?; 26 + let redis = client 27 + .get_multiplexed_async_connection() 28 + .await 29 + .into_diagnostic()?; 30 + 31 + info!("Job queue connected to Redis"); 32 + 33 + Ok(Self { 34 + redis, 35 + max_retries: config.processing.retry_attempts, 36 + }) 37 + } 38 + 39 + /// Push a job to the pending queue 40 + pub async fn push(&mut self, job: &ImageJob) -> Result<()> { 41 + let job_json = serde_json::to_string(job).into_diagnostic()?; 42 + 43 + let _: () = self 44 + .redis 45 + .rpush(PENDING_QUEUE, &job_json) 46 + .await 47 + .into_diagnostic()?; 48 + 49 + debug!("Pushed job to queue: {}", job.post_uri); 50 + 51 + Ok(()) 52 + } 53 + 54 + /// Pop a job from the pending queue (blocking with timeout) 55 + pub async fn pop(&mut self, timeout_secs: usize) -> Result<Option<ImageJob>> { 56 + let result: Option<Vec<String>> = self 57 + .redis 58 + .blpop(PENDING_QUEUE, timeout_secs as f64) 59 + .await 60 + .into_diagnostic()?; 61 + 62 + match result { 63 + Some(items) => { 64 + // blpop returns [key, value] 65 + if items.len() >= 2 { 66 + let job_json = &items[1]; 67 + let job: ImageJob = serde_json::from_str(job_json).into_diagnostic()?; 68 + debug!("Popped job from queue: {}", job.post_uri); 69 + Ok(Some(job)) 70 + } else { 71 + Ok(None) 72 + } 73 + } 74 + None => Ok(None), 75 + } 76 + } 77 + 78 + /// Retry a failed job (increment attempts and re-queue) 79 + pub async fn retry(&mut self, mut job: ImageJob) -> Result<()> { 80 + job.attempts += 1; 81 + 82 + if job.attempts >= self.max_retries { 83 + warn!( 84 + "Job exceeded max retries ({}), moving to dead letter queue: {}", 85 + self.max_retries, job.post_uri 86 + ); 87 + self.move_to_dead_letter(&job).await?; 88 + } else { 89 + info!( 90 + "Retrying job (attempt {}/{}): {}", 91 + job.attempts, self.max_retries, job.post_uri 92 + ); 93 + self.push(&job).await?; 94 + } 95 + 96 + Ok(()) 97 + } 98 + 99 + /// Move a job to the dead letter queue 100 + async fn move_to_dead_letter(&mut self, job: &ImageJob) -> Result<()> { 101 + let job_json = serde_json::to_string(job).into_diagnostic()?; 102 + 103 + let _: () = self 104 + .redis 105 + .rpush(DEAD_LETTER_QUEUE, &job_json) 106 + .await 107 + .into_diagnostic()?; 108 + 109 + warn!("Moved job to dead letter queue: {}", job.post_uri); 110 + 111 + Ok(()) 112 + } 113 + 114 + /// Get queue statistics 115 + pub async fn stats(&mut self) -> Result<QueueStats> { 116 + let pending: usize = self.redis.llen(PENDING_QUEUE).await.into_diagnostic()?; 117 + let processing: usize = self 118 + .redis 119 + .llen(PROCESSING_QUEUE) 120 + .await 121 + .into_diagnostic()?; 122 + let dead: usize = self.redis.llen(DEAD_LETTER_QUEUE).await.into_diagnostic()?; 123 + 124 + Ok(QueueStats { 125 + pending, 126 + processing, 127 + dead, 128 + }) 129 + } 130 + 131 + /// Clear all queues (for testing/maintenance) 132 + pub async fn clear_all(&mut self) -> Result<()> { 133 + let _: () = self.redis.del(PENDING_QUEUE).await.into_diagnostic()?; 134 + let _: () = self.redis.del(PROCESSING_QUEUE).await.into_diagnostic()?; 135 + let _: () = self 136 + .redis 137 + .del(DEAD_LETTER_QUEUE) 138 + .await 139 + .into_diagnostic()?; 140 + 141 + info!("Cleared all job queues"); 142 + 143 + Ok(()) 144 + } 145 + } 146 + 147 + #[derive(Debug, Clone)] 148 + pub struct QueueStats { 149 + pub pending: usize, 150 + pub processing: usize, 151 + pub dead: usize, 152 + } 153 + 154 + #[cfg(test)] 155 + mod tests { 156 + use super::*; 157 + 158 + // Note: These are integration tests that require a running Redis instance 159 + // Run with: cargo test --test queue -- --ignored 160 + }
+344
src/queue/worker.rs
··· 1 + use miette::Result; 2 + use reqwest::Client; 3 + use std::sync::Arc; 4 + use std::time::Duration; 5 + use tokio::time::sleep; 6 + use tracing::{error, info}; 7 + use jacquard::client::{Agent, MemoryCredentialSession}; 8 + use jacquard_api::com_atproto::moderation::ReasonType; 9 + 10 + use crate::agent::AgentSession; 11 + use crate::cache::PhashCache; 12 + use crate::config::Config; 13 + use crate::metrics::Metrics; 14 + use crate::moderation::{account, claims, post, rate_limiter::RateLimiter}; 15 + use crate::processor::matcher; 16 + use crate::queue::redis_queue::JobQueue; 17 + use crate::types::{BlobCheck, ImageJob, MatchResult}; 18 + 19 + /// Worker pool for processing image jobs 20 + pub struct WorkerPool { 21 + config: Config, 22 + client: Client, 23 + agent: AgentSession, 24 + blob_checks: Vec<BlobCheck>, 25 + metrics: Metrics, 26 + rate_limiter: RateLimiter, 27 + } 28 + 29 + impl WorkerPool { 30 + /// Create a new worker pool 31 + pub fn new( 32 + config: Config, 33 + client: Client, 34 + agent: AgentSession, 35 + blob_checks: Vec<BlobCheck>, 36 + metrics: Metrics, 37 + ) -> Self { 38 + let rate_limiter = RateLimiter::new(config.moderation.rate_limit); 39 + 40 + Self { 41 + config, 42 + client, 43 + agent, 44 + blob_checks, 45 + metrics, 46 + rate_limiter, 47 + } 48 + } 49 + 50 + /// Start the worker pool - processes jobs sequentially 51 + /// Concurrency is achieved by running multiple instances of this concurrently 52 + pub async fn start( 53 + &self, 54 + mut queue: JobQueue, 55 + mut cache: PhashCache, 56 + mut shutdown_rx: tokio::sync::broadcast::Receiver<()>, 57 + ) -> Result<()> { 58 + loop { 59 + tokio::select! { 60 + _ = shutdown_rx.recv() => { 61 + info!("Worker shutting down"); 62 + break; 63 + } 64 + 65 + job_result = queue.pop(1) => { 66 + match job_result { 67 + Ok(Some(job)) => { 68 + // Create new redis connection for this job 69 + let redis_client = match redis::Client::open(self.config.redis.url.as_str()) { 70 + Ok(c) => c, 71 + Err(e) => { 72 + error!("Failed to create Redis client: {}", e); 73 + continue; 74 + } 75 + }; 76 + 77 + let mut redis_conn = match redis_client.get_multiplexed_async_connection().await { 78 + Ok(conn) => conn, 79 + Err(e) => { 80 + error!("Failed to connect to Redis: {}", e); 81 + continue; 82 + } 83 + }; 84 + 85 + // Process job 86 + if let Err(e) = Self::process_job( 87 + &self.config, 88 + &self.client, 89 + self.agent.agent(), 90 + &self.blob_checks, 91 + &self.metrics, 92 + &self.rate_limiter, 93 + &mut cache, 94 + &mut redis_conn, 95 + job, 96 + &mut queue, 97 + self.agent.did(), 98 + ) 99 + .await 100 + { 101 + error!("Worker task failed: {}", e); 102 + } 103 + } 104 + Ok(None) => { 105 + // Timeout, continue loop 106 + } 107 + Err(e) => { 108 + error!("Error popping job from queue: {}", e); 109 + sleep(Duration::from_millis(self.config.processing.retry_delay)).await; 110 + } 111 + } 112 + } 113 + } 114 + } 115 + 116 + Ok(()) 117 + } 118 + 119 + /// Process a single job 120 + async fn process_job( 121 + config: &Config, 122 + client: &Client, 123 + agent: &Arc<Agent<MemoryCredentialSession>>, 124 + blob_checks: &[BlobCheck], 125 + metrics: &Metrics, 126 + rate_limiter: &RateLimiter, 127 + cache: &mut PhashCache, 128 + redis_conn: &mut redis::aio::MultiplexedConnection, 129 + job: ImageJob, 130 + queue: &mut JobQueue, 131 + created_by: &str, 132 + ) -> Result<()> { 133 + info!("Processing job: {}", job.post_uri); 134 + 135 + // Process all blobs and find matches 136 + let matches = Self::process_job_blobs(config, client, blob_checks, metrics, cache, &job).await?; 137 + 138 + if matches.is_empty() { 139 + info!("No matches found for job: {}", job.post_uri); 140 + metrics.inc_jobs_processed(); 141 + return Ok(()); 142 + } 143 + 144 + // Take moderation actions for each match 145 + for match_result in matches { 146 + if let Err(e) = 147 + Self::take_moderation_action(config, agent, metrics, rate_limiter, redis_conn, &job, &match_result, created_by) 148 + .await 149 + { 150 + error!("Failed to take moderation action: {}", e); 151 + metrics.inc_jobs_failed(); 152 + // Retry the job 153 + queue.retry(job.clone()).await?; 154 + return Err(e); 155 + } 156 + } 157 + 158 + info!("Successfully processed job: {}", job.post_uri); 159 + metrics.inc_jobs_processed(); 160 + 161 + Ok(()) 162 + } 163 + 164 + /// Process all blobs in a job and return matches 165 + async fn process_job_blobs( 166 + config: &Config, 167 + client: &Client, 168 + blob_checks: &[BlobCheck], 169 + metrics: &Metrics, 170 + cache: &mut PhashCache, 171 + job: &ImageJob, 172 + ) -> Result<Vec<MatchResult>> { 173 + let mut matches = Vec::new(); 174 + 175 + for blob in &job.blobs { 176 + metrics.inc_blobs_processed(); 177 + 178 + // Check cache first 179 + let cache_result = cache.get(&blob.cid).await?; 180 + let phash = if let Some(cached_phash) = cache_result { 181 + metrics.inc_cache_hits(); 182 + cached_phash 183 + } else { 184 + metrics.inc_cache_misses(); 185 + metrics.inc_blobs_downloaded(); 186 + 187 + // Download and compute 188 + let image_bytes = matcher::download_blob(client, config, &job.post_did, &blob.cid).await?; 189 + let computed_phash = crate::processor::phash::compute_phash(&image_bytes)?; 190 + 191 + // Store in cache 192 + cache.set(&blob.cid, &computed_phash).await?; 193 + computed_phash 194 + }; 195 + 196 + // Check for matches 197 + if let Some(match_result) = matcher::match_phash( 198 + &phash, 199 + blob_checks, 200 + &job.post_did, 201 + config.phash.default_hamming_threshold, 202 + ) { 203 + metrics.inc_matches_found(); 204 + matches.push(match_result); 205 + } 206 + } 207 + 208 + Ok(matches) 209 + } 210 + 211 + /// Take moderation action based on match result 212 + async fn take_moderation_action( 213 + config: &Config, 214 + agent: &Arc<Agent<MemoryCredentialSession>>, 215 + metrics: &Metrics, 216 + rate_limiter: &RateLimiter, 217 + redis_conn: &mut redis::aio::MultiplexedConnection, 218 + job: &ImageJob, 219 + match_result: &MatchResult, 220 + created_by: &str, 221 + ) -> Result<()> { 222 + let check = &match_result.matched_check; 223 + 224 + info!( 225 + "Taking moderation action for label '{}' on post: {}", 226 + check.label, job.post_uri 227 + ); 228 + 229 + // Report post if configured 230 + if check.report_post { 231 + if claims::claim_post_report(redis_conn, &job.post_uri, &check.label).await? { 232 + post::report_post( 233 + agent.as_ref(), 234 + config, 235 + rate_limiter, 236 + &job.post_uri, 237 + &job.post_cid, 238 + ReasonType::ComAtprotoModerationDefsReasonSpam, 239 + &check.comment, 240 + &match_result.phash, 241 + created_by, 242 + ) 243 + .await?; 244 + metrics.inc_posts_reported(); 245 + info!("Reported post: {}", job.post_uri); 246 + } else { 247 + metrics.inc_posts_already_reported(); 248 + info!("Post already reported, skipping: {}", job.post_uri); 249 + } 250 + } 251 + 252 + // Label post if configured 253 + if check.to_label { 254 + if !claims::has_label(redis_conn, &job.post_uri, &check.label).await? { 255 + post::label_post( 256 + agent.as_ref(), 257 + config, 258 + rate_limiter, 259 + &job.post_uri, 260 + &job.post_cid, 261 + &check.label, 262 + &check.comment, 263 + &match_result.phash, 264 + created_by, 265 + ) 266 + .await?; 267 + metrics.inc_posts_labeled(); 268 + claims::set_label(redis_conn, &job.post_uri, &check.label, None).await?; 269 + info!("Labeled post: {}", job.post_uri); 270 + } else { 271 + metrics.inc_posts_already_labeled(); 272 + info!("Post already labeled, skipping: {}", job.post_uri); 273 + } 274 + } 275 + 276 + // Report account if configured 277 + if check.report_acct { 278 + if claims::claim_account_report(redis_conn, &job.post_did, &check.label).await? { 279 + account::report_account( 280 + agent.as_ref(), 281 + config, 282 + rate_limiter, 283 + &job.post_did, 284 + ReasonType::ComAtprotoModerationDefsReasonSpam, 285 + &check.comment, 286 + &job.post_uri, 287 + &match_result.phash, 288 + created_by, 289 + ) 290 + .await?; 291 + metrics.inc_accounts_reported(); 292 + info!("Reported account: {}", job.post_did); 293 + } else { 294 + metrics.inc_accounts_already_reported(); 295 + info!("Account already reported, skipping: {}", job.post_did); 296 + } 297 + } 298 + 299 + // Label account if configured 300 + if check.label_acct { 301 + if !claims::has_label(redis_conn, &job.post_did, &check.label).await? { 302 + account::label_account( 303 + agent.as_ref(), 304 + config, 305 + rate_limiter, 306 + &job.post_did, 307 + &check.label, 308 + &check.comment, 309 + &job.post_uri, 310 + &match_result.phash, 311 + created_by, 312 + ) 313 + .await?; 314 + metrics.inc_accounts_labeled(); 315 + claims::set_label(redis_conn, &job.post_did, &check.label, None).await?; 316 + info!("Labeled account: {}", job.post_did); 317 + } else { 318 + metrics.inc_accounts_already_labeled(); 319 + info!("Account already labeled, skipping: {}", job.post_did); 320 + } 321 + } 322 + 323 + Ok(()) 324 + } 325 + } 326 + 327 + // Manual Clone implementation 328 + impl Clone for WorkerPool { 329 + fn clone(&self) -> Self { 330 + Self { 331 + config: self.config.clone(), 332 + client: self.client.clone(), 333 + agent: self.agent.clone(), 334 + blob_checks: self.blob_checks.clone(), 335 + metrics: self.metrics.clone(), 336 + rate_limiter: self.rate_limiter.clone(), 337 + } 338 + } 339 + } 340 + 341 + #[cfg(test)] 342 + mod tests { 343 + // Note: Worker tests require integration testing with Redis 344 + }
+164
src/types/README.md
··· 1 + # Types Module 2 + 3 + ## Purpose 4 + Shared data structures used across the entire application for jobs, rules, matches, and blob references. 5 + 6 + ## Key Types 7 + 8 + ### `BlobCheck` 9 + Rule definition for matching and taking action on bad images. 10 + 11 + **Fields:** 12 + - `phashes: Vec<String>` - List of known bad phashes (16-char hex) 13 + - `label: String` - Label to apply (e.g., "spam", "csam", "troll") 14 + - `comment: String` - Human-readable description of violation 15 + - `report_acct: bool` - Report the account to moderators 16 + - `label_acct: bool` - Apply label to account 17 + - `report_post: bool` - Report the post to moderators 18 + - `to_label: bool` - Apply label to post 19 + - `takedown_post: bool` - Takedown/remove the post 20 + - `takedown_acct: bool` - Takedown/suspend the account 21 + - `hamming_threshold: Option<u32>` - Max distance for match (overrides global) 22 + - `description: Option<String>` - Optional human description 23 + - `ignore_did: Option<Vec<String>>` - DIDs to skip checking 24 + 25 + **JSON format (rules/blobs.json):** 26 + ```json 27 + { 28 + "phashes": ["e0e0e0e0e0fcfefe"], 29 + "label": "spam", 30 + "comment": "Known spam image detected", 31 + "reportAcct": false, 32 + "labelAcct": true, 33 + "reportPost": true, 34 + "toLabel": true, 35 + "takedownPost": false, 36 + "takedownAcct": false, 37 + "hammingThreshold": 3, 38 + "description": "Spam campaign from Oct 2024", 39 + "ignoreDID": ["did:plc:exempted-account"] 40 + } 41 + ``` 42 + 43 + **Usage:** 44 + - Loaded at startup from JSON file 45 + - Cloned into each worker 46 + - Used by `match_phash()` to determine matches 47 + - Defines what actions to take on match 48 + 49 + ### `BlobReference` 50 + Reference to an image blob in a post. 51 + 52 + **Fields:** 53 + - `cid: String` - Content identifier (blob hash) 54 + - `mime_type: Option<String>` - MIME type (e.g., "image/jpeg") 55 + 56 + **Source:** Extracted from Jetstream post records 57 + **Usage:** Part of `ImageJob`, tells worker which blobs to check 58 + 59 + ### `ImageJob` 60 + A job representing a post with images to check. 61 + 62 + **Fields:** 63 + - `post_uri: String` - AT-URI of the post (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc") 64 + - `post_cid: String` - Post commit CID 65 + - `post_did: String` - DID of post author 66 + - `blobs: Vec<BlobReference>` - Images to check 67 + - `timestamp: i64` - Unix timestamp when job created 68 + - `attempts: u32` - Retry counter (0 = first attempt) 69 + 70 + **Lifecycle:** 71 + 1. Created by Jetstream client from post event 72 + 2. Pushed to Redis queue (JSON serialized) 73 + 3. Popped by worker 74 + 4. Processed (download, compute, match) 75 + 5. If retry needed: `attempts++`, push back to queue 76 + 6. If max retries: move to dead letter queue 77 + 78 + ### `MatchResult` 79 + Result of a successful phash match. 80 + 81 + **Fields:** 82 + - `phash: String` - Computed phash from image 83 + - `matched_check: BlobCheck` - Which rule matched 84 + - `matched_phash: String` - Specific phash from rule that matched 85 + - `hamming_distance: u32` - How close the match was (0 = exact) 86 + 87 + **Usage:** 88 + - Returned by `match_phash()` on successful match 89 + - Contains all info needed for moderation actions 90 + - Passed to `take_moderation_action()` 91 + - Used to build detailed moderation comments 92 + 93 + **Example:** 94 + ```rust 95 + MatchResult { 96 + phash: "e0e0e0e0e0fcfefe", 97 + matched_check: BlobCheck { label: "spam", ... }, 98 + matched_phash: "e0e0e0e0fcfef0", 99 + hamming_distance: 2, // 2 bits different 100 + } 101 + ``` 102 + 103 + ## Serde Configuration 104 + 105 + All types use `#[serde(rename_all = "camelCase")]`: 106 + - Rust: `report_acct` 107 + - JSON: `reportAcct` 108 + 109 + **Why?** JSON format matches AT Protocol conventions (camelCase). 110 + 111 + ## Type Flow 112 + 113 + ``` 114 + Jetstream Event 115 + ↓ extract blobs 116 + BlobReference (cid, mime_type) 117 + ↓ create job 118 + ImageJob (post_uri, post_cid, post_did, blobs, ...) 119 + ↓ serialize to JSON 120 + Redis Queue 121 + ↓ deserialize from JSON 122 + Worker 123 + ↓ download & compute 124 + ↓ match against 125 + BlobCheck (phashes, label, actions, ...) 126 + ↓ if match 127 + MatchResult (phash, matched_check, distance) 128 + ↓ execute actions 129 + Moderation API 130 + ``` 131 + 132 + ## Design Decisions 133 + 134 + **Why separate BlobReference from ImageJob?** 135 + - Post can have multiple images 136 + - Each image needs separate processing 137 + - But all share same post metadata 138 + 139 + **Why include post_uri in ImageJob?** 140 + - Needed for moderation actions 141 + - Included in moderation comments 142 + - Used for deduplication in claims 143 + 144 + **Why store attempts in ImageJob?** 145 + - Retry logic needs to track failure count 146 + - Prevents infinite retry loops 147 + - Used to determine dead letter queue 148 + 149 + **Why include matched_check in MatchResult?** 150 + - Need to know which actions to take 151 + - Need comment for moderation API 152 + - Avoids re-looking up the rule 153 + 154 + ## Dependencies 155 + 156 + - `serde` - Serialization/deserialization 157 + - All types derive: `Debug`, `Clone`, `Serialize`, `Deserialize` 158 + 159 + ## Related Modules 160 + 161 + - Used by: All modules - these are the core data types 162 + - Loaded by: `processor::matcher` - BlobCheck from JSON 163 + - Created by: `jetstream` - ImageJob and BlobReference 164 + - Returned by: `processor::matcher` - MatchResult
+51
src/types/mod.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Serialize, Deserialize)] 4 + #[serde(rename_all = "camelCase")] 5 + pub struct BlobCheck { 6 + pub phashes: Vec<String>, 7 + pub label: String, 8 + pub comment: String, 9 + pub report_acct: bool, 10 + pub label_acct: bool, 11 + pub report_post: bool, 12 + pub to_label: bool, 13 + #[serde(default)] 14 + pub takedown_post: bool, 15 + #[serde(default)] 16 + pub takedown_acct: bool, 17 + #[serde(default)] 18 + pub hamming_threshold: Option<u32>, 19 + #[serde(default)] 20 + pub description: Option<String>, 21 + #[serde(default, alias = "ignoreDID")] 22 + pub ignore_did: Option<Vec<String>>, 23 + } 24 + 25 + #[derive(Debug, Clone, Serialize, Deserialize)] 26 + #[serde(rename_all = "camelCase")] 27 + pub struct BlobReference { 28 + pub cid: String, 29 + #[serde(default)] 30 + pub mime_type: Option<String>, 31 + } 32 + 33 + #[derive(Debug, Clone, Serialize, Deserialize)] 34 + #[serde(rename_all = "camelCase")] 35 + pub struct ImageJob { 36 + pub post_uri: String, 37 + pub post_cid: String, 38 + pub post_did: String, 39 + pub blobs: Vec<BlobReference>, 40 + pub timestamp: i64, 41 + pub attempts: u32, 42 + } 43 + 44 + #[derive(Debug, Clone, Serialize, Deserialize)] 45 + #[serde(rename_all = "camelCase")] 46 + pub struct MatchResult { 47 + pub phash: String, 48 + pub matched_check: BlobCheck, 49 + pub matched_phash: String, 50 + pub hamming_distance: u32, 51 + }
tests/integration/queue.rs

This is a binary file and will not be displayed.

tests/unit/matcher.rs

This is a binary file and will not be displayed.

tests/unit/phash.rs

This is a binary file and will not be displayed.