diff --git a/.codesight/CODESIGHT.md b/.codesight/CODESIGHT.md deleted file mode 100644 index dc2a0d0..0000000 --- a/.codesight/CODESIGHT.md +++ /dev/null @@ -1,18 +0,0 @@ -# nzb-core — AI Context Map - -> **Stack:** raw-http | none | unknown | rust - -> 0 routes | 0 models | 0 components | 0 lib files | 0 env vars | 0 middleware -> **Token savings:** this file is ~100 tokens. Without it, AI exploration would cost ~3,700 tokens. **Saves ~3,600 tokens per conversation.** - ---- - -# Config - -## Config Files - -- `Cargo.toml` - ---- - -_Generated by [codesight](https://github.com/Houseofmvps/codesight) — see your codebase clearly_ \ No newline at end of file diff --git a/.codesight/config.md b/.codesight/config.md deleted file mode 100644 index 736f013..0000000 --- a/.codesight/config.md +++ /dev/null @@ -1,5 +0,0 @@ -# Config - -## Config Files - -- `Cargo.toml` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 1d91edc..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @TheDancingDeveloper-org/migration-maintainers diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 74cb186..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -updates: - - package-ecosystem: cargo - directory: / - schedule: - interval: weekly - open-pull-requests-limit: 10 - groups: - rust-minor: - update-types: [minor, patch] - - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 59191d9..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - rust: - # Dependabot metadata PRs do not consume the self-hosted validation runner. - if: github.event_name != 'pull_request' || github.event.pull_request.user.type != 'Bot' - runs-on: [self-hosted] - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt,clippy - - run: cargo fmt --all -- --check - - run: cargo check --workspace --locked - - run: cargo test --workspace --locked - - run: cargo clippy --workspace --all-targets --locked -- -D warnings diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml deleted file mode 100644 index 8e9b508..0000000 --- a/.github/workflows/policy.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Migration policy - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - policy: - # Dependabot metadata PRs do not consume the self-hosted policy runner. - if: github.event_name != 'pull_request' || github.event.pull_request.user.type != 'Bot' - runs-on: [self-hosted] - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: Verify migration policies - shell: bash - run: bash ci/migration-policy.sh diff --git a/.github/workflows/runner-policy.yml b/.github/workflows/runner-policy.yml deleted file mode 100644 index 65a1c1f..0000000 --- a/.github/workflows/runner-policy.yml +++ /dev/null @@ -1,16 +0,0 @@ -# Copy this file to .github/workflows/runner-policy.yml in every repository. -# -# It is deliberately tiny: all logic lives in the reusable workflow in -# github-policy, so the rule can be changed in one place. After adding it, make -# `runner-policy` a required status check on the repository's protected branch -# — without that it reports but does not block. -name: Runner policy - -on: - pull_request: - push: - branches: [main] - -jobs: - runner-policy: - uses: TheDancingDeveloper-org/github-policy/.github/workflows/runner-policy-reusable.yml@main \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index c92913c..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,124 +0,0 @@ -when: - - event: push - branch: main - - event: manual - - event: pull_request - -steps: - - name: fmt - image: rust:1.94-bookworm - environment: - GIT_AUTH_TOKEN: - from_secret: git_auth_token - RUSTC_WRAPPER: sccache - SCCACHE_REDIS: "redis://100.92.54.45:6380" - commands: - - curl -sSfL "https://github.com/mozilla/sccache/releases/download/v0.8.2/sccache-v0.8.2-x86_64-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && mv /tmp/sccache-v0.8.2-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache - - git config --global url."http://x-access-token:$GIT_AUTH_TOKEN@100.92.54.45:3002/".insteadOf "http://100.92.54.45:3002/" - - printf '[registries.forgejo]\nindex = "sparse+https://repo.indexarr.net/api/packages/indexarr/cargo/"\ncredential-provider = "cargo:token"\n\n[registry]\ndefault = "forgejo"\n' > $CARGO_HOME/config.toml - - printf '[registries.forgejo]\ntoken = "Bearer %s"\n' "$GIT_AUTH_TOKEN" > $CARGO_HOME/credentials.toml - - rustup component add rustfmt - - cargo fmt --all -- --check - - - name: check - image: rust:1.94-bookworm - environment: - GIT_AUTH_TOKEN: - from_secret: git_auth_token - RUSTC_WRAPPER: sccache - SCCACHE_REDIS: "redis://100.92.54.45:6380" - commands: - - curl -sSfL "https://github.com/mozilla/sccache/releases/download/v0.8.2/sccache-v0.8.2-x86_64-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && mv /tmp/sccache-v0.8.2-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache - - git config --global url."http://x-access-token:$GIT_AUTH_TOKEN@100.92.54.45:3002/".insteadOf "http://100.92.54.45:3002/" - - printf '[registries.forgejo]\nindex = "sparse+https://repo.indexarr.net/api/packages/indexarr/cargo/"\ncredential-provider = "cargo:token"\n\n[registry]\ndefault = "forgejo"\n' > $CARGO_HOME/config.toml - - printf '[registries.forgejo]\ntoken = "Bearer %s"\n' "$GIT_AUTH_TOKEN" > $CARGO_HOME/credentials.toml - - cargo check --workspace - - - name: test - image: rust:1.94-bookworm - environment: - GIT_AUTH_TOKEN: - from_secret: git_auth_token - RUSTC_WRAPPER: sccache - SCCACHE_REDIS: "redis://100.92.54.45:6380" - commands: - - curl -sSfL "https://github.com/mozilla/sccache/releases/download/v0.8.2/sccache-v0.8.2-x86_64-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && mv /tmp/sccache-v0.8.2-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache - - git config --global url."http://x-access-token:$GIT_AUTH_TOKEN@100.92.54.45:3002/".insteadOf "http://100.92.54.45:3002/" - - printf '[registries.forgejo]\nindex = "sparse+https://repo.indexarr.net/api/packages/indexarr/cargo/"\ncredential-provider = "cargo:token"\n\n[registry]\ndefault = "forgejo"\n' > $CARGO_HOME/config.toml - - printf '[registries.forgejo]\ntoken = "Bearer %s"\n' "$GIT_AUTH_TOKEN" > $CARGO_HOME/credentials.toml - - cargo test --workspace - - - name: clippy - image: rust:1.94-bookworm - environment: - GIT_AUTH_TOKEN: - from_secret: git_auth_token - RUSTC_WRAPPER: sccache - SCCACHE_REDIS: "redis://100.92.54.45:6380" - commands: - - curl -sSfL "https://github.com/mozilla/sccache/releases/download/v0.8.2/sccache-v0.8.2-x86_64-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && mv /tmp/sccache-v0.8.2-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache - - git config --global url."http://x-access-token:$GIT_AUTH_TOKEN@100.92.54.45:3002/".insteadOf "http://100.92.54.45:3002/" - - printf '[registries.forgejo]\nindex = "sparse+https://repo.indexarr.net/api/packages/indexarr/cargo/"\ncredential-provider = "cargo:token"\n\n[registry]\ndefault = "forgejo"\n' > $CARGO_HOME/config.toml - - printf '[registries.forgejo]\ntoken = "Bearer %s"\n' "$GIT_AUTH_TOKEN" > $CARGO_HOME/credentials.toml - - rustup component add clippy - - cargo clippy --workspace -- -D warnings - - - name: publish - image: rust:1.94-bookworm - environment: - GIT_AUTH_TOKEN: - from_secret: git_auth_token - commands: - - printf '[registries.forgejo]\nindex = "sparse+https://repo.indexarr.net/api/packages/indexarr/cargo/"\ncredential-provider = "cargo:token"\n\n[registry]\ndefault = "forgejo"\n' > $CARGO_HOME/config.toml - - printf '[registries.forgejo]\ntoken = "Bearer %s"\n' "$GIT_AUTH_TOKEN" > $CARGO_HOME/credentials.toml - - cargo publish --allow-dirty 2>&1 || echo "Publish skipped (version may already exist)" - when: - - event: push - branch: main - - - name: publish-crates-io - image: rust:1.94-bookworm - environment: - CARGO_REGISTRY_TOKEN: - from_secret: cargo_crates_io_token - commands: - - sed -i 's/, registry = "forgejo"//g; s/registry = "forgejo", //g' Cargo.toml - - rm -f Cargo.lock - - cargo publish --allow-dirty --no-verify 2>&1 || echo "crates.io publish skipped (version may already exist)" - when: - - event: push - branch: main - - # trigger-renovate step removed — woodpecker_token secret not configured, - # which errored the pipeline at secret-validation time. Re-add when provisioned. - - # push-github step removed — gh_release_token secret missing errors - # pipeline at validation. Re-add once provisioned. - - - name: discord-success - image: alpine/curl - environment: - DISCORD_WEBHOOK_URL: - from_secret: discord_webhook_url - commands: - - | - curl -s -X POST "$DISCORD_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "{\"embeds\":[{\"title\":\"nzb-core published\",\"description\":\"Published to Forgejo registry.\",\"color\":3066993}]}" - when: - - event: push - branch: main - status: [success] - - - name: discord-failure - image: alpine/curl - environment: - DISCORD_WEBHOOK_URL: - from_secret: discord_webhook_url - commands: - - | - curl -s -X POST "$DISCORD_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "{\"embeds\":[{\"title\":\"nzb-core build failed\",\"description\":\"Check CI logs for details\",\"color\":15158332}]}" - when: - - status: [failure] diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ea816b5..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,54 +0,0 @@ -# nzb-core - -Shared models, config, NZB parser, and SQLite database for NZB clients. - -**Version:** 0.1.1 | **Edition:** Rust 2024 | **License:** MIT - -## This Is a Shared Library - -### Consumed By - -| App | Via | Tag | -|-----|-----|-----| -| rustnzbd | git | v0.1.1 | -| Arz | git | v0.1.0 | -| rustnzbindxer | vendored (path) | — | -| NGMS | vendored (path) | — | -| nzb-postproc (lib) | git | v0.1.1 | - -### Depends On - -- **nzb-nntp** (git, v0.1.0) — for `ServerConfig` and NNTP types - -## Features - -- `groups-db` — enables optional `groups_db` module for newsgroup database operations - -## Public API - -```rust -pub mod config; // AppConfig, GeneralConfig, ServerConfig, CategoryConfig, OtelConfig, RssFeedConfig -pub mod db; // Database — SQLite job/article/segment queries -pub mod error; // NzbError, Result -pub mod models; // JobStatus, Priority, NzbJob, ServerArticleStats, etc. -pub mod nzb_parser; // NZB XML file parsing -pub mod sabnzbd_import; // SABnzbd config import utility - -#[cfg(feature = "groups-db")] -pub mod groups_db; // Newsgroup database operations -``` - -### Key Types - -- **`AppConfig`** — top-level TOML config (general, servers, categories, otel, rss_feeds) -- **`Database`** — SQLite wrapper for job/article/segment CRUD -- **`NzbJob`** — complete download job with stats -- **`JobStatus`** — Queued, Downloading, Paused, Verifying, Repairing, Extracting, PostProcessing, Completed, Failed -- **`Priority`** — Low(0), Normal(1), High(2), Force(3) -- **`GeneralConfig`** — listen_addr, port, api_key, dirs, speed_limit, cache_size, log_level - -## Key Dependencies - -- rusqlite (bundled SQLite) -- quick-xml (NZB XML parsing) -- chrono, uuid, serde, toml diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 077b5a7..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1574 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-timer" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-core", - "futures-sink", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - -[[package]] -name = "governor" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" -dependencies = [ - "cfg-if", - "dashmap", - "futures-sink", - "futures-timer", - "futures-util", - "getrandom 0.3.4", - "hashbrown 0.16.1", - "nonzero_ext", - "parking_lot", - "portable-atomic", - "quanta", - "rand", - "smallvec", - "spinning_top", - "web-time", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nonzero_ext" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "nzb-core" -version = "0.2.17" -dependencies = [ - "anyhow", - "chrono", - "nzb-nntp", - "parking_lot", - "proptest", - "quick-xml", - "rusqlite", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "toml", - "tracing", - "unicode-normalization", - "uuid", -] - -[[package]] -name = "nzb-nntp" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f41caecf94c0a12ec78ba5cfaad05581f1934505cae5ddd163b272add5346b" -dependencies = [ - "anyhow", - "arc-swap", - "bytes", - "flate2", - "governor", - "parking_lot", - "rustls", - "rustls-pki-types", - "serde", - "sha2", - "socket2 0.5.10", - "thiserror 2.0.19", - "tokio", - "tokio-rustls", - "tokio-socks", - "tracing", - "uuid", - "webpki-roots", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "quanta" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spinning_top" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" -dependencies = [ - "lock_api", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl 2.0.19", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2 0.6.5", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-socks" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" -dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "uuid" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index abf137b..c127be5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-core" -version = "0.2.17" +version = "0.2.18" edition = "2024" description = "Shared models, config, NZB parser, and SQLite database for NZB clients" license = "MIT" @@ -12,10 +12,10 @@ default = [] groups-db = [] [dependencies] -nzb-nntp = { version = "0.2.23" } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp" } serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "0.8" +toml = "1.1" rusqlite = { version = "0.32", features = ["bundled"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } @@ -23,7 +23,7 @@ thiserror = "2" anyhow = "1" tracing = "0.1" parking_lot = "0.12" -quick-xml = "0.37" +quick-xml = "0.41" unicode-normalization = "0.1" [dev-dependencies] diff --git a/ci/migration-policy.sh b/ci/migration-policy.sh deleted file mode 100755 index e269fd2..0000000 --- a/ci/migration-policy.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -forbidden="$(printf '%s%s%s' 'sp' 'roo' 'ty')" -failures=0 -fail() { printf 'migration policy: %s\n' "$1" >&2; failures=$((failures + 1)); } -if git grep -Iqi -- "$forbidden" HEAD --; then - git grep -Ini -- "$forbidden" HEAD -- >&2 || true - fail 'forbidden legacy identifier found in tracked content' -fi -while IFS= read -r -d '' path; do - if [[ "${path,,}" == *"$forbidden"* ]]; then - printf '%s\n' "$path" >&2 - fail 'forbidden legacy identifier found in tracked path' - fi -done < <(git ls-files -z) -workflow_matches="$(git grep -nE 'runs-on:[[:space:]]*(ubuntu|windows|macos)-|runs-on:[[:space:]]*.*(ubuntu|windows|macos|arm).*latest|runs-on:[[:space:]]*\$\{\{' HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml' || true)" -if [[ -n "$workflow_matches" ]]; then - printf '%s\n' "$workflow_matches" >&2 - fail 'workflow selects a hosted or unresolved dynamic runner' -fi -if [[ -n "${GITHUB_BASE_REF:-}" ]]; then - # Fetch the base ref with full history (not --depth=1): a shallow base ref - # grafts away its ancestry, so `origin/BASE..HEAD` can no longer tell that - # the base tip's ancestors are shared with HEAD. On a branch that has merged - # the base in (e.g. after "Update branch"), that made historical base commits - # reappear in the range as false positives. - git fetch --no-tags origin "$GITHUB_BASE_REF" >/dev/null 2>&1 || true - # Scope to commits this PR actually introduces: everything since the branch - # diverged from the base (merge-base), which excludes commits merged in from - # the base itself. - base="$(git merge-base "origin/$GITHUB_BASE_REF" HEAD 2>/dev/null || true)" - commits="$(git rev-list --reverse "${base:+$base..}HEAD" 2>/dev/null || git rev-list --reverse HEAD~1..HEAD 2>/dev/null || git rev-list --reverse HEAD)" -elif [[ "${GITHUB_EVENT_NAME:-}" == push && "${GITHUB_BEFORE:-}" != 0000000000000000000000000000000000000000 ]]; then - commits="$(git rev-list --reverse "${GITHUB_BEFORE:-}..HEAD" 2>/dev/null || git rev-list --reverse HEAD~1..HEAD 2>/dev/null || git rev-list --reverse HEAD)" -else - commits="$(git rev-list --reverse HEAD~1..HEAD 2>/dev/null || git rev-list --reverse HEAD)" -fi -while IFS= read -r commit; do - [[ -n "$commit" ]] || continue - metadata="$(git show -s --format='%an%n%ae%n%cn%n%ce%n%B' "$commit")" - if grep -qi -- "$forbidden" <<<"$metadata"; then - printf '%s\n' "$commit" >&2 - fail 'forbidden legacy identifier found in commit metadata' - fi -done <<<"$commits" -(( failures == 0 )) || exit 1 -printf 'migration policy passed\n' diff --git a/src/config.rs b/src/config.rs index b803d40..c5a56f2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,10 +54,20 @@ pub struct GeneralConfig { pub log_level: String, /// Log file path (None = stdout only) pub log_file: Option, - /// History retention: how many NZBs to keep in history (None = keep all) + /// History retention: how many NZBs to keep in history. + /// `None` or `Some(0)` both mean keep all; see [`normalize_history_retention`]. pub history_retention: Option, /// Max number of NZBs downloading simultaneously (default 1) pub max_active_downloads: usize, + /// Max number of independent jobs in post-processing at once. + #[serde(default = "default_max_post_processing_jobs")] + pub max_post_processing_jobs: usize, + /// Max number of concurrent PAR2 verify/repair workers. + #[serde(default = "default_max_repair_workers")] + pub max_repair_workers: usize, + /// Max number of concurrent archive extraction workers. + #[serde(default = "default_max_extract_workers")] + pub max_extract_workers: usize, /// Minimum free disk space in bytes before pausing downloads (default 1 GB) #[serde(default = "default_min_free_space")] pub min_free_space_bytes: u64, @@ -98,6 +108,27 @@ pub struct GeneralConfig { /// 0 = no timeout. Default: 30. #[serde(default = "default_article_timeout_secs")] pub article_timeout_secs: u64, + /// Sort queued jobs by remaining percentage whenever progress changes. + #[serde(default)] + pub auto_sort_remaining_pct: bool, + /// Remove downloaded RSS records after this many days. None keeps them. + #[serde(default)] + pub rss_downloaded_item_expiry_days: Option, + /// Optional directory containing post-processing scripts. + #[serde(default)] + pub scripts_dir: Option, + /// Script run after a successful post-processing job. + #[serde(default)] + pub script_success: Option, + /// Script run after a failed post-processing job. + #[serde(default)] + pub script_failure: Option, + /// Maximum runtime for a post-processing script. + #[serde(default = "default_script_timeout_secs")] + pub script_timeout_secs: u64, + /// Maximum captured output retained from a post-processing script. + #[serde(default = "default_script_output_bytes")] + pub script_max_output_bytes: usize, } fn default_rss_history_limit() -> Option { @@ -108,6 +139,18 @@ fn default_min_free_space() -> u64 { 1_073_741_824 // 1 GB } +fn default_max_post_processing_jobs() -> usize { + 2 +} + +fn default_max_repair_workers() -> usize { + 1 +} + +fn default_max_extract_workers() -> usize { + 1 +} + fn default_required_completion_pct() -> f64 { 100.2 } @@ -120,6 +163,24 @@ fn default_article_timeout_secs() -> u64 { 30 } +/// Normalize a history retention limit so that `0` means "keep all". +/// +/// SABnzbd users (and this codebase's own `speed_limit_bps`) treat `0` as +/// unlimited. Enforcing a literal limit of zero would delete every history +/// row immediately after each completion (GH #136), so a zero is folded into +/// `None` at every entry point before it reaches the database. +pub fn normalize_history_retention(limit: Option) -> Option { + limit.filter(|max| *max > 0) +} + +fn default_script_timeout_secs() -> u64 { + 300 +} + +fn default_script_output_bytes() -> usize { + 1024 * 1024 +} + impl Default for GeneralConfig { fn default() -> Self { Self { @@ -135,6 +196,9 @@ impl Default for GeneralConfig { log_file: None, history_retention: None, // keep all max_active_downloads: 1, + max_post_processing_jobs: default_max_post_processing_jobs(), + max_repair_workers: default_max_repair_workers(), + max_extract_workers: default_max_extract_workers(), min_free_space_bytes: default_min_free_space(), watch_dir: None, rss_history_limit: default_rss_history_limit(), @@ -144,6 +208,13 @@ impl Default for GeneralConfig { early_failure_check: true, required_completion_pct: default_required_completion_pct(), article_timeout_secs: default_article_timeout_secs(), + auto_sort_remaining_pct: false, + rss_downloaded_item_expiry_days: None, + scripts_dir: None, + script_success: None, + script_failure: None, + script_timeout_secs: default_script_timeout_secs(), + script_max_output_bytes: default_script_output_bytes(), } } } @@ -214,6 +285,12 @@ pub struct CategoryConfig { pub output_dir: Option, /// Post-processing level: 0=none, 1=repair, 2=unpack, 3=repair+unpack pub post_processing: u8, + /// Filename or relative-path glob patterns to remove after unpacking. + #[serde(default)] + pub cleanup_patterns: Vec, + /// Extensions to remove after unpacking, including or omitting the dot. + #[serde(default)] + pub unwanted_extensions: Vec, } impl Default for CategoryConfig { @@ -222,6 +299,8 @@ impl Default for CategoryConfig { name: "Default".into(), output_dir: None, post_processing: 3, + cleanup_patterns: Vec::new(), + unwanted_extensions: Vec::new(), } } } @@ -249,6 +328,9 @@ pub struct RssFeedConfig { /// Ignored when filter_regex is set (use download rules instead). #[serde(default)] pub auto_download: bool, + /// Ignore entries older than this many days. None disables age filtering. + #[serde(default)] + pub max_age_days: Option, } fn default_poll_interval() -> u64 { @@ -374,6 +456,9 @@ mod tests { assert!(cfg.log_file.is_none()); assert!(cfg.history_retention.is_none()); assert_eq!(cfg.max_active_downloads, 1); + assert_eq!(cfg.max_post_processing_jobs, 2); + assert_eq!(cfg.max_repair_workers, 1); + assert_eq!(cfg.max_extract_workers, 1); assert_eq!(cfg.min_free_space_bytes, 1_073_741_824); assert!(cfg.watch_dir.is_none()); assert_eq!(cfg.rss_history_limit, Some(500)); @@ -381,6 +466,13 @@ mod tests { assert_eq!(cfg.max_nested_archive_depth, 5); } + #[test] + fn zero_history_retention_normalizes_to_keep_all() { + assert_eq!(normalize_history_retention(Some(0)), None); + assert_eq!(normalize_history_retention(None), None); + assert_eq!(normalize_history_retention(Some(25)), Some(25)); + } + #[test] fn direct_unpack_can_be_explicitly_disabled() { let cfg: GeneralConfig = toml::from_str("direct_unpack = false").unwrap(); @@ -512,6 +604,7 @@ mod tests { name: "movies".into(), output_dir: Some("/movies".into()), post_processing: 3, + ..CategoryConfig::default() }); assert!(cfg.category("Default").is_some()); @@ -539,6 +632,7 @@ mod tests { name: "movies".into(), output_dir: None, post_processing: 3, + ..CategoryConfig::default() }); assert_eq!(cfg.find_category_or_default("movies").name, "movies"); assert_eq!(cfg.find_category_or_default("unknown").name, "Default"); diff --git a/src/damage_ledger.rs b/src/damage_ledger.rs new file mode 100644 index 0000000..01b0de2 --- /dev/null +++ b/src/damage_ledger.rs @@ -0,0 +1,727 @@ +//! Per-article damage ledger (WI-143). +//! +//! One durable record per article says whether it is `present`, missing, or +//! not yet established, together with the per-server evidence behind that +//! verdict. Both the download engine and the streaming provider consult and +//! update this ledger, so a hole learned once is never rediscovered. +//! +//! The rules that decide a verdict live in exactly one function, +//! [`apply_outcome`], and follow zurg's `internal/nzb/holes.go`: +//! +//! - A transport failure (timeout, auth error, pool exhaustion, decode error) +//! NEVER creates or refreshes a missing record. Only a `430` does. +//! - A single `430` is provisional. It confirms only once every enabled server +//! has refused the article and at least [`HOLE_CONFIRM_DELAY_SECS`] seconds +//! have passed since the first refusal — a provider is known to answer `430` +//! for an article it holds while busy. +//! - A confirmed hole is believed for [`HOLE_TTL_SECS`]; after that a caller +//! re-probes. +//! - The record is keyed to the set of servers that were asked +//! ([`server_fingerprint`]); adding or removing a provider invalidates it. +//! +//! This crate does not depend on the dispatcher, so callers translate their +//! own failure taxonomy into an [`Outcome`] before calling [`Database::ledger_note`]: +//! a `2xx` body is [`Outcome::Present`], a `430` is [`Outcome::Refused`], and +//! every other failure is [`Outcome::Transport`]. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Duration, Utc}; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +use crate::db::Database; +use crate::error::NzbError; + +/// How long a confirmed hole is believed before a re-probe (zurg `holeTTL`). +pub const HOLE_TTL_SECS: i64 = 24 * 60 * 60; +/// Minimum gap between the first `430` and the confirming `430` +/// (zurg `holeConfirmDelay`). +pub const HOLE_CONFIRM_DELAY_SECS: i64 = 1; + +/// The verdict recorded for one article. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedgerState { + /// Never asked, or only inconclusive (transport) answers so far. + Unknown, + /// One or more `430`s, but not yet confirmed missing everywhere. + ProvisionalMissing, + /// Every enabled server refused it and the busy-provider guard elapsed. + ConfirmedMissing, + /// A definite `2xx` body was seen; the article is present. + Present, + /// The file this article belongs to is past the unservable threshold. + UnservableFile, +} + +impl LedgerState { + fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::ProvisionalMissing => "provisional_missing", + Self::ConfirmedMissing => "confirmed_missing", + Self::Present => "present", + Self::UnservableFile => "unservable_file", + } + } + + fn from_str(s: &str) -> Self { + match s { + "provisional_missing" => Self::ProvisionalMissing, + "confirmed_missing" => Self::ConfirmedMissing, + "present" => Self::Present, + "unservable_file" => Self::UnservableFile, + _ => Self::Unknown, + } + } +} + +/// Identity of one article within a job or streamed item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArticleKey { + pub scope_id: String, + pub file_index: u32, + pub segment_number: u32, +} + +impl ArticleKey { + pub fn new(scope_id: impl Into, file_index: u32, segment_number: u32) -> Self { + Self { + scope_id: scope_id.into(), + file_index, + segment_number, + } + } +} + +/// The result of a single fetch attempt, in the ledger's own vocabulary. +/// Callers map their transport/decode/auth errors onto [`Outcome::Transport`] +/// and only a real `430` onto [`Outcome::Refused`]. +#[derive(Debug, Clone)] +pub enum Outcome { + Present, + Refused { server: String }, + Transport { server: String }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum EvidenceKind { + Refused, + Transport, +} + +/// What one server last said about this article. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ServerEvidence { + pub server: String, + pub kind: EvidenceKind, + pub at: DateTime, +} + +/// A persisted per-article damage record. +#[derive(Debug, Clone)] +pub struct LedgerRecord { + pub key: ArticleKey, + pub message_id: String, + pub state: LedgerState, + pub evidence: Vec, + pub first_refused_at: Option>, + pub confirmed_at: Option>, + pub expires_at: Option>, + pub server_fingerprint: String, +} + +impl LedgerRecord { + fn new(key: ArticleKey, message_id: String) -> Self { + Self { + key, + message_id, + state: LedgerState::Unknown, + evidence: Vec::new(), + first_refused_at: None, + confirmed_at: None, + expires_at: None, + server_fingerprint: String::new(), + } + } + + fn refused_servers(&self) -> BTreeSet<&str> { + self.evidence + .iter() + .filter(|e| e.kind == EvidenceKind::Refused) + .map(|e| e.server.as_str()) + .collect() + } +} + +/// Whether the caller should schedule a confirming re-ask before trusting a +/// provisional refusal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfirmNeeded(pub bool); + +/// Guidance from [`Database::ledger_consult`] before fetching an article. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Consult { + /// The article is a confirmed hole within its TTL — do not fetch it. + pub skip: bool, + /// The confirmed hole's TTL has expired — one caller should re-probe. + pub reprobe: bool, +} + +/// A stable fingerprint of the set of servers that were asked. Adding or +/// removing a provider changes it, which invalidates any prior verdict. +pub fn server_fingerprint(servers: &[String]) -> String { + let mut set: Vec<&str> = servers.iter().map(String::as_str).collect(); + set.sort_unstable(); + set.dedup(); + set.join(",") +} + +fn record_evidence(rec: &mut LedgerRecord, server: &str, kind: EvidenceKind, now: DateTime) { + if let Some(existing) = rec.evidence.iter_mut().find(|e| e.server == server) { + existing.kind = kind; + existing.at = now; + } else { + rec.evidence.push(ServerEvidence { + server: server.to_string(), + kind, + at: now, + }); + } +} + +/// The single place that decides how one outcome mutates a record. Every rule +/// about what may be recorded as missing lives here and nowhere else. Returns +/// whether a confirming re-ask is still needed. +pub fn apply_outcome( + rec: &mut LedgerRecord, + outcome: &Outcome, + now: DateTime, + enabled_servers: &[String], +) -> ConfirmNeeded { + let fingerprint = server_fingerprint(enabled_servers); + if rec.server_fingerprint != fingerprint { + // The set of servers asked changed; a hole keyed to the old set is no + // longer trustworthy. Start the record over. + rec.state = LedgerState::Unknown; + rec.evidence.clear(); + rec.first_refused_at = None; + rec.confirmed_at = None; + rec.expires_at = None; + rec.server_fingerprint = fingerprint; + } + + match outcome { + Outcome::Present => { + // A definite present answer is terminal and clears any hole. + rec.state = LedgerState::Present; + rec.first_refused_at = None; + rec.confirmed_at = None; + rec.expires_at = None; + ConfirmNeeded(false) + } + Outcome::Transport { server } => { + // A transport failure is inconclusive: record who was unreachable, + // but never let it create or advance a missing verdict. + record_evidence(rec, server, EvidenceKind::Transport, now); + ConfirmNeeded(false) + } + Outcome::Refused { server } => { + record_evidence(rec, server, EvidenceKind::Refused, now); + + // Present is sticky (we already hold the bytes); a confirmed hole + // stays confirmed until its TTL is handled by consult(). + if matches!( + rec.state, + LedgerState::Present | LedgerState::ConfirmedMissing + ) { + return ConfirmNeeded(false); + } + + if rec.first_refused_at.is_none() { + rec.first_refused_at = Some(now); + } + rec.state = LedgerState::ProvisionalMissing; + + let refused = rec.refused_servers(); + let all_refused = !enabled_servers.is_empty() + && enabled_servers.iter().all(|s| refused.contains(s.as_str())); + let elapsed_ok = rec + .first_refused_at + .is_some_and(|first| now - first >= Duration::seconds(HOLE_CONFIRM_DELAY_SECS)); + + if all_refused && elapsed_ok { + rec.state = LedgerState::ConfirmedMissing; + rec.confirmed_at = Some(now); + rec.expires_at = Some(now + Duration::seconds(HOLE_TTL_SECS)); + ConfirmNeeded(false) + } else { + ConfirmNeeded(true) + } + } + } +} + +/// Whether a fetch should be skipped or re-probed given the current record. +pub fn consult(rec: Option<&LedgerRecord>, now: DateTime) -> Consult { + match rec.map(|r| (r.state, r.expires_at)) { + Some((LedgerState::ConfirmedMissing | LedgerState::UnservableFile, expires)) => { + match expires { + Some(exp) if now >= exp => Consult { + skip: false, + reprobe: true, + }, + _ => Consult { + skip: true, + reprobe: false, + }, + } + } + _ => Consult { + skip: false, + reprobe: false, + }, + } +} + +/// Whether a reader blocked on this article is waiting on a provisional +/// refusal that a confirm may yet clear. +pub fn held_provisionally(rec: Option<&LedgerRecord>) -> bool { + matches!(rec.map(|r| r.state), Some(LedgerState::ProvisionalMissing)) +} + +fn parse_dt(s: &str) -> Option> { + DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&Utc)) +} + +impl Database { + /// Record one fetch outcome for an article and return whether a confirming + /// re-ask is still needed. `enabled_servers` is the full set of servers + /// this fetch could have used; it fingerprints the record. + pub fn ledger_note( + &self, + key: &ArticleKey, + message_id: &str, + outcome: &Outcome, + enabled_servers: &[String], + now: DateTime, + ) -> Result { + let mut rec = self + .ledger_get(key)? + .unwrap_or_else(|| LedgerRecord::new(key.clone(), message_id.to_string())); + if rec.message_id.is_empty() { + rec.message_id = message_id.to_string(); + } + let confirm = apply_outcome(&mut rec, outcome, now, enabled_servers); + self.ledger_upsert(&rec)?; + Ok(confirm) + } + + /// Guidance before fetching an article: skip a live hole, re-probe an + /// expired one, otherwise fetch normally. + pub fn ledger_consult( + &self, + key: &ArticleKey, + now: DateTime, + ) -> Result { + Ok(consult(self.ledger_get(key)?.as_ref(), now)) + } + + /// Whether the article is currently held on a provisional refusal. + pub fn ledger_held_provisionally(&self, key: &ArticleKey) -> Result { + Ok(held_provisionally(self.ledger_get(key)?.as_ref())) + } + + /// Fraction of a file's recorded articles that are confirmed gone + /// (confirmed_missing or an unservable file). Returns 0.0 with no records. + pub fn ledger_file_gone_ratio(&self, scope_id: &str, file_index: u32) -> Result { + let total: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM damage_ledger WHERE scope_id = ?1 AND file_index = ?2", + params![scope_id, file_index as i64], + |row| row.get(0), + )?; + if total == 0 { + return Ok(0.0); + } + let gone: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM damage_ledger + WHERE scope_id = ?1 AND file_index = ?2 + AND state IN ('confirmed_missing', 'unservable_file')", + params![scope_id, file_index as i64], + |row| row.get(0), + )?; + Ok(gone as f64 / total as f64) + } + + /// All ledger records for one file, ordered by segment number. + pub fn ledger_load_file( + &self, + scope_id: &str, + file_index: u32, + ) -> Result, NzbError> { + let mut stmt = self.conn.prepare( + "SELECT segment_number, message_id, state, evidence, + first_refused_at, confirmed_at, expires_at, server_fingerprint + FROM damage_ledger + WHERE scope_id = ?1 AND file_index = ?2 + ORDER BY segment_number", + )?; + let rows = stmt.query_map(params![scope_id, file_index as i64], |row| { + let segment_number = row.get::<_, i64>(0)? as u32; + let message_id: String = row.get(1)?; + let state: String = row.get(2)?; + let evidence_json: String = row.get::<_, Option>(3)?.unwrap_or_default(); + let first: Option = row.get(4)?; + let confirmed: Option = row.get(5)?; + let expires: Option = row.get(6)?; + let fingerprint: String = row.get(7)?; + Ok(LedgerRecord { + key: ArticleKey::new(scope_id, file_index, segment_number), + message_id, + state: LedgerState::from_str(&state), + evidence: serde_json::from_str(&evidence_json).unwrap_or_default(), + first_refused_at: first.as_deref().and_then(parse_dt), + confirmed_at: confirmed.as_deref().and_then(parse_dt), + expires_at: expires.as_deref().and_then(parse_dt), + server_fingerprint: fingerprint, + }) + })?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + } + + fn ledger_get(&self, key: &ArticleKey) -> Result, NzbError> { + let result = self.conn.query_row( + "SELECT message_id, state, evidence, first_refused_at, + confirmed_at, expires_at, server_fingerprint + FROM damage_ledger + WHERE scope_id = ?1 AND file_index = ?2 AND segment_number = ?3", + params![ + key.scope_id, + key.file_index as i64, + key.segment_number as i64 + ], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?.unwrap_or_default(), + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + )) + }, + ); + match result { + Ok((message_id, state, evidence_json, first, confirmed, expires, fingerprint)) => { + Ok(Some(LedgerRecord { + key: key.clone(), + message_id, + state: LedgerState::from_str(&state), + evidence: serde_json::from_str(&evidence_json).unwrap_or_default(), + first_refused_at: first.as_deref().and_then(parse_dt), + confirmed_at: confirmed.as_deref().and_then(parse_dt), + expires_at: expires.as_deref().and_then(parse_dt), + server_fingerprint: fingerprint, + })) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(NzbError::Database(e)), + } + } + + fn ledger_upsert(&self, rec: &LedgerRecord) -> Result<(), NzbError> { + let evidence = serde_json::to_string(&rec.evidence).unwrap_or_else(|_| "[]".to_string()); + self.conn.execute( + "INSERT INTO damage_ledger + (scope_id, file_index, segment_number, message_id, state, evidence, + first_refused_at, confirmed_at, expires_at, server_fingerprint) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(scope_id, file_index, segment_number) DO UPDATE SET + message_id = excluded.message_id, + state = excluded.state, + evidence = excluded.evidence, + first_refused_at = excluded.first_refused_at, + confirmed_at = excluded.confirmed_at, + expires_at = excluded.expires_at, + server_fingerprint = excluded.server_fingerprint", + params![ + rec.key.scope_id, + rec.key.file_index as i64, + rec.key.segment_number as i64, + rec.message_id, + rec.state.as_str(), + evidence, + rec.first_refused_at.map(|d| d.to_rfc3339()), + rec.confirmed_at.map(|d| d.to_rfc3339()), + rec.expires_at.map(|d| d.to_rfc3339()), + rec.server_fingerprint, + ], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn servers(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + + fn key() -> ArticleKey { + ArticleKey::new("job-1", 0, 3) + } + + fn t(secs: i64) -> DateTime { + DateTime::from_timestamp(1_700_000_000 + secs, 0).unwrap() + } + + #[test] + fn transient_430_then_200_is_present() { + let db = Database::open_memory().unwrap(); + let srv = servers(&["a", "b"]); + + let c = db + .ledger_note( + &key(), + "", + &Outcome::Refused { server: "a".into() }, + &srv, + t(0), + ) + .unwrap(); + assert_eq!(c, ConfirmNeeded(true)); + assert!(db.ledger_held_provisionally(&key()).unwrap()); + + db.ledger_note(&key(), "", &Outcome::Present, &srv, t(1)) + .unwrap(); + + assert!(!db.ledger_held_provisionally(&key()).unwrap()); + assert_eq!( + db.ledger_consult(&key(), t(2)).unwrap(), + Consult { + skip: false, + reprobe: false + } + ); + assert_eq!( + db.ledger_load_file("job-1", 0).unwrap()[0].state, + LedgerState::Present + ); + } + + #[test] + fn refused_on_every_server_confirms_after_delay() { + let db = Database::open_memory().unwrap(); + let srv = servers(&["a", "b"]); + + db.ledger_note( + &key(), + "", + &Outcome::Refused { server: "a".into() }, + &srv, + t(0), + ) + .unwrap(); + // Both refused but within the confirm delay: still provisional. + let c = db + .ledger_note( + &key(), + "", + &Outcome::Refused { server: "b".into() }, + &srv, + t(0), + ) + .unwrap(); + assert_eq!(c, ConfirmNeeded(true)); + assert!(db.ledger_held_provisionally(&key()).unwrap()); + + // A later refusal past the delay confirms the hole. + let c = db + .ledger_note( + &key(), + "", + &Outcome::Refused { server: "b".into() }, + &srv, + t(2), + ) + .unwrap(); + assert_eq!(c, ConfirmNeeded(false)); + assert_eq!( + db.ledger_consult(&key(), t(2)).unwrap(), + Consult { + skip: true, + reprobe: false + } + ); + } + + #[test] + fn transport_on_last_server_never_confirms() { + let db = Database::open_memory().unwrap(); + let srv = servers(&["a", "b"]); + + db.ledger_note( + &key(), + "", + &Outcome::Refused { server: "a".into() }, + &srv, + t(0), + ) + .unwrap(); + // b only ever fails transport — it never definitively refused. + db.ledger_note( + &key(), + "", + &Outcome::Transport { server: "b".into() }, + &srv, + t(5), + ) + .unwrap(); + db.ledger_note( + &key(), + "", + &Outcome::Refused { server: "a".into() }, + &srv, + t(10), + ) + .unwrap(); + + assert!(db.ledger_held_provisionally(&key()).unwrap()); + assert_eq!( + db.ledger_consult(&key(), t(10)).unwrap(), + Consult { + skip: false, + reprobe: false + } + ); + } + + #[test] + fn confirmed_hole_reprobes_after_ttl() { + let db = Database::open_memory().unwrap(); + let srv = servers(&["a", "b"]); + + db.ledger_note( + &key(), + "", + &Outcome::Refused { server: "a".into() }, + &srv, + t(0), + ) + .unwrap(); + db.ledger_note( + &key(), + "", + &Outcome::Refused { server: "b".into() }, + &srv, + t(2), + ) + .unwrap(); + assert_eq!( + db.ledger_consult(&key(), t(2)).unwrap(), + Consult { + skip: true, + reprobe: false + } + ); + + // Within TTL: still skip. Past TTL: re-probe. + assert_eq!( + db.ledger_consult(&key(), t(2 + HOLE_TTL_SECS - 1)).unwrap(), + Consult { + skip: true, + reprobe: false + } + ); + assert_eq!( + db.ledger_consult(&key(), t(2 + HOLE_TTL_SECS + 1)).unwrap(), + Consult { + skip: false, + reprobe: true + } + ); + } + + #[test] + fn adding_a_server_invalidates_the_hole() { + let db = Database::open_memory().unwrap(); + + // Confirm a hole against {a, b}. + db.ledger_note( + &key(), + "", + &Outcome::Refused { server: "a".into() }, + &servers(&["a", "b"]), + t(0), + ) + .unwrap(); + db.ledger_note( + &key(), + "", + &Outcome::Refused { server: "b".into() }, + &servers(&["a", "b"]), + t(2), + ) + .unwrap(); + assert_eq!( + db.ledger_load_file("job-1", 0).unwrap()[0].state, + LedgerState::ConfirmedMissing + ); + + // A newly added server c changes the fingerprint: the record resets and + // a single refusal is provisional again, not confirmed. + let c = db + .ledger_note( + &key(), + "", + &Outcome::Refused { server: "a".into() }, + &servers(&["a", "b", "c"]), + t(3), + ) + .unwrap(); + assert_eq!(c, ConfirmNeeded(true)); + let rec = &db.ledger_load_file("job-1", 0).unwrap()[0]; + assert_eq!(rec.state, LedgerState::ProvisionalMissing); + assert_eq!(rec.refused_servers().len(), 1); + } + + #[test] + fn file_gone_ratio_counts_confirmed_only() { + let db = Database::open_memory().unwrap(); + let srv = servers(&["a"]); + + // segment 0: confirmed missing + let k0 = ArticleKey::new("job-2", 0, 0); + db.ledger_note( + &k0, + "", + &Outcome::Refused { server: "a".into() }, + &srv, + t(0), + ) + .unwrap(); + db.ledger_note( + &k0, + "", + &Outcome::Refused { server: "a".into() }, + &srv, + t(2), + ) + .unwrap(); + // segment 1: present + let k1 = ArticleKey::new("job-2", 0, 1); + db.ledger_note(&k1, "", &Outcome::Present, &srv, t(0)) + .unwrap(); + + assert_eq!(db.ledger_file_gone_ratio("job-2", 0).unwrap(), 0.5); + } +} diff --git a/src/db.rs b/src/db.rs index 7b9c6e3..ca12883 100644 --- a/src/db.rs +++ b/src/db.rs @@ -309,6 +309,46 @@ impl Database { )?; } + if version < 9 { + info!("Applying database migration v9: per-article retry outcomes"); + self.conn.execute_batch( + " + ALTER TABLE history ADD COLUMN retry_data BLOB; + DELETE FROM schema_version; + INSERT INTO schema_version (version) VALUES (9); + ", + )?; + } + + if version < 10 { + info!("Applying database migration v10: per-article damage ledger"); + // Created empty. `history.retry_data` (v9) is a retry checkpoint, + // not damage evidence, so there is no backfill: it carries no + // failure reason, per-server evidence, TTL, or server fingerprint + // to reconstruct a confirmed-missing record from. See WI-143. + self.conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS damage_ledger ( + scope_id TEXT NOT NULL, + file_index INTEGER NOT NULL, + segment_number INTEGER NOT NULL, + message_id TEXT NOT NULL, + state TEXT NOT NULL, + evidence TEXT, + first_refused_at TEXT, + confirmed_at TEXT, + expires_at TEXT, + server_fingerprint TEXT NOT NULL DEFAULT '', + PRIMARY KEY (scope_id, file_index, segment_number) + ); + CREATE INDEX IF NOT EXISTS idx_damage_ledger_file + ON damage_ledger (scope_id, file_index); + DELETE FROM schema_version; + INSERT INTO schema_version (version) VALUES (10); + ", + )?; + } + Ok(()) } @@ -469,8 +509,8 @@ impl Database { let server_stats_json = serde_json::to_string(&entry.server_stats).unwrap_or_default(); self.conn.execute( "INSERT INTO history (id, name, category, status, total_bytes, downloaded_bytes, - added_at, completed_at, download_time_secs, output_dir, stages, error_message, nzb_data, server_stats) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + added_at, completed_at, download_time_secs, output_dir, stages, error_message, nzb_data, server_stats, retry_data) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", params![ entry.id, entry.name, @@ -486,6 +526,7 @@ impl Database { entry.error_message, entry.nzb_data, server_stats_json, + entry.retry_data, ], )?; @@ -580,6 +621,7 @@ impl Database { server_stats, // Don't load actual blob in list - just note if it exists nzb_data: if has_nzb != 0 { Some(Vec::new()) } else { None }, + retry_data: None, }) })? .collect::, _>>()?; @@ -601,8 +643,30 @@ impl Database { } } + /// Get persisted per-article outcomes for a history retry. + pub fn history_get_retry_data(&self, id: &str) -> Result>, NzbError> { + let result = self.conn.query_row( + "SELECT retry_data FROM history WHERE id = ?1", + params![id], + |row| row.get::<_, Option>>(0), + ); + match result { + Ok(data) => Ok(data), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(NzbError::Database(e)), + } + } + /// Enforce history retention limit by deleting oldest entries. + /// + /// A limit of `0` is treated as "keep all" and is a no-op: the + /// `LIMIT 0` subquery would otherwise match nothing and delete every + /// row (GH #136). Callers normalize zero away already; this is the last + /// line of defence. pub fn history_enforce_retention(&self, max_entries: usize) -> Result<(), NzbError> { + if max_entries == 0 { + return Ok(()); + } self.conn.execute( "DELETE FROM history WHERE id NOT IN ( SELECT id FROM history ORDER BY completed_at DESC LIMIT ?1 @@ -642,6 +706,7 @@ impl Database { error_message: row.get(11)?, server_stats, nzb_data: None, + retry_data: None, }) }); @@ -885,6 +950,15 @@ impl Database { Ok(deleted) } + /// Remove downloaded RSS items older than `cutoff` and return the count. + pub fn rss_items_expire_downloaded(&self, cutoff: &str) -> Result { + Ok(self.conn.execute( + "DELETE FROM rss_items WHERE downloaded = 1 AND downloaded_at IS NOT NULL + AND downloaded_at < ?1", + params![cutoff], + )?) + } + fn map_rss_item(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(RssItem { id: row.get(0)?, @@ -1065,6 +1139,7 @@ mod tests { error_message: None, server_stats: Vec::new(), nzb_data: None, + retry_data: None, } } @@ -1305,6 +1380,19 @@ mod tests { assert_eq!(db.history_count().unwrap(), 3); } + /// GH #136: a retention limit of zero must not wipe history. + #[test] + fn test_history_enforce_retention_zero_keeps_all() { + let db = Database::open_memory().unwrap(); + for i in 0..3 { + db.history_insert(&make_history(&format!("ret0-{i}"), &format!("Job {i}"))) + .unwrap(); + } + + db.history_enforce_retention(0).unwrap(); + assert_eq!(db.history_count().unwrap(), 3); + } + #[test] fn test_history_store_and_get_logs() { let db = Database::open_memory().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 56beda5..754b7ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,18 @@ pub mod config; +pub mod damage_ledger; pub mod db; pub mod error; #[cfg(feature = "groups-db")] pub mod groups_db; pub mod models; pub mod nzb_parser; +pub mod path; pub mod sabnzbd_import; pub use config::AppConfig; +pub use damage_ledger::{ + ArticleKey, ConfirmNeeded, Consult, LedgerRecord, LedgerState, Outcome, server_fingerprint, +}; pub use db::Database; pub use error::{NzbError, Result}; pub use models::*; diff --git a/src/models.rs b/src/models.rs index b12ab03..bbffaea 100644 --- a/src/models.rs +++ b/src/models.rs @@ -205,6 +205,9 @@ pub struct HistoryEntry { /// Raw NZB XML data (for retry) #[serde(skip_serializing)] pub nzb_data: Option>, + /// Serialized per-article outcomes used for missing-only retry. + #[serde(skip_serializing)] + pub retry_data: Option>, } /// Immutable statistics ledger row recorded when a job leaves the queue. diff --git a/src/nzb_parser.rs b/src/nzb_parser.rs index 87ea673..25387b1 100644 --- a/src/nzb_parser.rs +++ b/src/nzb_parser.rs @@ -1,5 +1,7 @@ use std::path::Path; +use quick_xml::XmlVersion; +use quick_xml::escape::resolve_predefined_entity; use quick_xml::events::Event; use quick_xml::reader::Reader; use tracing::warn; @@ -37,7 +39,6 @@ pub fn parse_nzb(name: &str, data: &[u8]) -> Result { } let mut reader = Reader::from_reader(data); - reader.config_mut().trim_text(true); let decoder = reader.decoder(); let mut files: Vec = Vec::new(); @@ -49,18 +50,24 @@ pub fn parse_nzb(name: &str, data: &[u8]) -> Result { let mut buf = Vec::new(); let mut meta_password: Option = None; let mut reading_password_meta = false; + // quick-xml >= 0.38 no longer unescapes text inline: `&` arrives as a + // separate `GeneralRef` event between two `Text` events, and CDATA is its + // own event too. Element content is therefore accumulated here and only + // consumed when the element closes. + let mut text_buf = String::new(); loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(ref e)) => match e.local_name().as_ref() { b"file" => { + text_buf.clear(); let mut subject = String::new(); let mut date = 0i64; for attr in e.attributes().flatten() { match attr.key.as_ref() { b"subject" => { subject = attr - .decode_and_unescape_value(decoder) + .decoded_and_normalized_value(XmlVersion::Explicit1_0, decoder) .map(|v| v.into_owned()) .unwrap_or_else(|_| { String::from_utf8_lossy(&attr.value).into_owned() @@ -80,9 +87,10 @@ pub fn parse_nzb(name: &str, data: &[u8]) -> Result { current_segments.clear(); } b"groups" => in_groups = true, - b"group" => {} + b"group" => text_buf.clear(), b"segments" => in_segments = true, b"segment" => { + text_buf.clear(); let mut number = 0u32; let mut bytes = 0u64; for attr in e.attributes().flatten() { @@ -103,6 +111,7 @@ pub fn parse_nzb(name: &str, data: &[u8]) -> Result { }); } b"meta" => { + text_buf.clear(); for attr in e.attributes().flatten() { if attr.key.as_ref() == b"type" && attr.value.as_ref() == b"password" { reading_password_meta = true; @@ -170,24 +179,49 @@ pub fn parse_nzb(name: &str, data: &[u8]) -> Result { } } b"groups" => in_groups = false, + b"group" => { + let group = text_buf.trim(); + if in_groups && !group.is_empty() { + current_groups.push(group.to_string()); + } + text_buf.clear(); + } b"segments" => in_segments = false, + b"segment" => { + if in_segments && let Some(seg) = current_segments.last_mut() { + seg.message_id = text_buf.trim().to_string(); + } + text_buf.clear(); + } + b"meta" => { + if reading_password_meta { + meta_password = Some(text_buf.trim().to_string()); + reading_password_meta = false; + } + text_buf.clear(); + } _ => {} }, - Ok(Event::Text(ref t)) => { - let text = t.unescape().unwrap_or_default().into_owned(); - if reading_password_meta { - meta_password = Some(text); - reading_password_meta = false; - } else if in_groups { - current_groups.push(text); - } else if in_segments && let Some(seg) = current_segments.last_mut() { - seg.message_id = text; - } - } + Ok(Event::Text(ref t)) => match t.decode() { + Ok(text) => text_buf.push_str(&text), + Err(_) => text_buf.push_str(&String::from_utf8_lossy(t)), + }, // Some NZB generators wrap message-IDs in CDATA sections. Ok(Event::CData(ref c)) => { - if in_segments && let Some(seg) = current_segments.last_mut() { - seg.message_id = String::from_utf8_lossy(c.as_ref()).into_owned(); + text_buf.push_str(&String::from_utf8_lossy(c.as_ref())); + } + // `&`, `&`, `&` and friends inside element content. + Ok(Event::GeneralRef(ref r)) => { + let name = String::from_utf8_lossy(r.as_ref()); + if let Some(ch) = resolve_char_ref(&name) { + text_buf.push(ch); + } else if let Some(text) = resolve_predefined_entity(&name) { + text_buf.push_str(text); + } else { + // Unknown entity: keep it verbatim rather than dropping data. + text_buf.push('&'); + text_buf.push_str(&name); + text_buf.push(';'); } } Ok(Event::Eof) => break, @@ -238,6 +272,16 @@ pub fn parse_nzb(name: &str, data: &[u8]) -> Result { } /// Parse NZB from a file path. +/// Resolve a numeric character reference body (`#38` or `#x26`) to its char. +fn resolve_char_ref(name: &str) -> Option { + let digits = name.strip_prefix('#')?; + let code = match digits.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => digits.parse::().ok()?, + }; + char::from_u32(code) +} + pub fn parse_nzb_file(path: &Path) -> Result { let data = std::fs::read(path)?; let name = path @@ -488,6 +532,42 @@ mod tests { assert_eq!(job.files[0].articles[0].message_id, "article1@example.com"); } + /// quick-xml >= 0.38 splits element content around entity references, so + /// a message-ID or password containing `&` must be reassembled rather + /// than truncated to its last fragment. + #[test] + fn test_parse_nzb_entities_in_text_and_attributes() { + let nzb_data = br#" + + + my & secret & more & end + + + + alt.binaries.test + alt.binaries.b&w + + + part1&2@example.com + + + +"#; + + let job = parse_nzb("entities", nzb_data).unwrap(); + assert_eq!(job.password.as_deref(), Some("my & secret & more & end")); + assert_eq!(job.files[0].filename, "a & b.rar"); + assert_eq!( + job.files[0].groups, + vec![ + "alt.binaries.test".to_string(), + "alt.binaries.b&w".to_string() + ] + ); + assert_eq!(job.files[0].articles[0].message_id, "part1&2@example.com"); + assert_eq!(job.files[0].articles[1].message_id, "cdata&raw@example.com"); + } + #[test] fn test_parse_nzb_multiple_files() { let nzb_data = br#" diff --git a/src/path.rs b/src/path.rs new file mode 100644 index 0000000..0cf3d80 --- /dev/null +++ b/src/path.rs @@ -0,0 +1,58 @@ +//! Path validation shared by download and post-processing boundaries. + +use std::path::{Path, PathBuf}; + +/// Join an untrusted archive or API supplied relative name beneath `root`. +/// Backslashes are treated as separators on every platform. +pub fn safe_join(root: &Path, name: &str) -> Option { + let normalized = name.replace('\\', "/"); + if normalized.is_empty() + || normalized.starts_with('/') + || normalized.as_bytes().get(1) == Some(&b':') + || normalized.chars().any(char::is_control) + { + return None; + } + + let mut output = root.to_path_buf(); + for component in normalized.split('/') { + match component { + "" | "." => {} + ".." => return None, + component => output.push(component), + } + } + output.starts_with(root).then_some(output) +} + +/// Validate a user supplied directory or category component. +/// These values are intentionally a single path component. +pub fn safe_component(value: &str) -> Option<&str> { + if value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + || value.chars().any(char::is_control) + { + return None; + } + Some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_join_rejects_cross_platform_escape_paths() { + let root = Path::new("/tmp/job"); + assert!(safe_join(root, "folder/file.txt").is_some()); + for path in ["../outside", r"..\outside", "/etc/passwd", r"C:\temp"] { + assert!(safe_join(root, path).is_none(), "{path}"); + } + assert!(safe_component("movies").is_some()); + assert!(safe_component("../outside").is_none()); + assert!(safe_component(r"movies\tv").is_none()); + } +} diff --git a/src/sabnzbd_import.rs b/src/sabnzbd_import.rs index a6a024e..5b3535d 100644 --- a/src/sabnzbd_import.rs +++ b/src/sabnzbd_import.rs @@ -79,6 +79,28 @@ pub struct ImportedGeneral { // Helpers // --------------------------------------------------------------------------- +/// Warn when an imported complete_dir/incomplete_dir isn't an absolute path. +/// rustnzb creates these directories eagerly at startup via `create_dir_all`, +/// which resolves a relative path against the process's working directory +/// rather than any intended download volume — applying such a path produces +/// a confusing crash far from the actual misconfiguration (see issue #62). +fn warn_relative_dirs(general: &ImportedGeneral, warnings: &mut Vec) { + if let Some(ref dir) = general.complete_dir + && !std::path::Path::new(dir).is_absolute() + { + warnings.push(format!( + "complete_dir '{dir}' is a relative path and must be made absolute before this import can be applied" + )); + } + if let Some(ref dir) = general.incomplete_dir + && !std::path::Path::new(dir).is_absolute() + { + warnings.push(format!( + "incomplete_dir '{dir}' is a relative path and must be made absolute before this import can be applied" + )); + } +} + /// Parse SABnzbd bandwidth limit string (e.g. "50M", "1G", "500K", "0", ""). pub fn parse_bandwidth_limit(s: &str) -> u64 { let s = s.trim().trim_matches('"'); @@ -188,6 +210,7 @@ pub fn parse_sabnzbd_ini(content: &str) -> SabnzbdImportPreview { .map(|s| parse_bandwidth_limit(s)) .unwrap_or(0), }; + warn_relative_dirs(&general, &mut warnings); // --- Servers (from [servers] → [[name]]) --- let servers: Vec = sections @@ -221,6 +244,7 @@ pub fn parse_sabnzbd_ini(content: &str) -> SabnzbdImportPreview { .filter(|s| !s.is_empty()) .map(std::path::PathBuf::from), post_processing: kv.get("pp").and_then(|s| s.parse().ok()).unwrap_or(3), + ..CategoryConfig::default() } }) .collect(); @@ -256,6 +280,7 @@ pub fn parse_sabnzbd_ini(content: &str) -> SabnzbdImportPreview { filter_regex, enabled: kv.get("enable").map(|s| parse_ini_bool(s)).unwrap_or(true), auto_download: false, + max_age_days: None, }) }) .collect(); @@ -313,6 +338,7 @@ pub fn parse_sabnzbd_api_response(json: &serde_json::Value) -> SabnzbdImportPrev .or_else(|| misc["bandwidth_limit"].as_u64()) .unwrap_or(0), }; + warn_relative_dirs(&general, &mut warnings); // --- Servers --- let servers: Vec = config["servers"] @@ -400,6 +426,7 @@ pub fn parse_sabnzbd_api_response(json: &serde_json::Value) -> SabnzbdImportPrev .as_u64() .or_else(|| c["pp"].as_str().and_then(|p| p.parse().ok())) .unwrap_or(3) as u8, + ..CategoryConfig::default() } }) .collect() @@ -548,6 +575,77 @@ mod tests { assert_eq!(preview.warnings.len(), 1); } + #[test] + fn ini_import_warns_on_relative_complete_and_incomplete_dir() { + let preview = parse_sabnzbd_ini( + r#" + [misc] + complete_dir = Downloads + download_dir = Downloads/incomplete + "#, + ); + + assert!( + preview + .warnings + .iter() + .any(|w| w.contains("complete_dir") && w.contains("Downloads")), + "expected a relative complete_dir warning, got: {:?}", + preview.warnings + ); + assert!( + preview + .warnings + .iter() + .any(|w| w.contains("incomplete_dir") && w.contains("Downloads/incomplete")), + "expected a relative incomplete_dir warning, got: {:?}", + preview.warnings + ); + } + + #[test] + fn ini_import_does_not_warn_on_absolute_complete_and_incomplete_dir() { + let preview = parse_sabnzbd_ini( + r#" + [misc] + complete_dir = /downloads/complete + download_dir = /downloads/incomplete + "#, + ); + + assert!( + preview.warnings.is_empty(), + "expected no warnings for absolute dirs, got: {:?}", + preview.warnings + ); + } + + #[test] + fn api_import_warns_on_relative_complete_and_incomplete_dir() { + let preview = parse_sabnzbd_api_response(&serde_json::json!({ + "config": { + "misc": { "complete_dir": "Downloads", "download_dir": "Downloads/incomplete" } + } + })); + + assert!( + preview + .warnings + .iter() + .any(|w| w.contains("complete_dir") && w.contains("Downloads")), + "expected a relative complete_dir warning, got: {:?}", + preview.warnings + ); + assert!( + preview + .warnings + .iter() + .any(|w| w.contains("incomplete_dir") && w.contains("Downloads/incomplete")), + "expected a relative incomplete_dir warning, got: {:?}", + preview.warnings + ); + } + proptest! { #[test] fn arbitrary_ini_input_never_panics(input in ".{0,4096}") {