diff --git a/.codesight/CODESIGHT.md b/.codesight/CODESIGHT.md deleted file mode 100644 index 9d7c4c2..0000000 --- a/.codesight/CODESIGHT.md +++ /dev/null @@ -1,18 +0,0 @@ -# nzb-postproc — 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 ~1,900 tokens. **Saves ~1,800 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 aff8582..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,125 +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: - - apt-get install -y --no-install-recommends sccache >/dev/null 2>&1 || true - - 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: - - apt-get install -y --no-install-recommends sccache >/dev/null 2>&1 || true - - 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: - - apt-get install -y --no-install-recommends sccache >/dev/null 2>&1 || true - - 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: - - apt-get install -y --no-install-recommends sccache >/dev/null 2>&1 || true - - 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_CRATES_IO_TOKEN: - from_secret: cargo_crates_io_token - commands: - - printf '[registries.crates-io]\ntoken = "%s"\n' "$CARGO_CRATES_IO_TOKEN" > $CARGO_HOME/credentials.toml - - sed -i 's/, registry = "forgejo"//g; s/registry = "forgejo", //g' Cargo.toml - - rm -f Cargo.lock - - cargo publish --registry crates-io --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-postproc 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-postproc 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 a4ac8d1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,59 +0,0 @@ -# nzb-postproc - -Post-processing pipeline: PAR2 verify/repair, archive extraction. - -**Version:** 0.1.2 | **Edition:** Rust 2024 | **License:** MIT - -## This Is a Shared Library - -### Consumed By - -| App | Via | Tag | -|-----|-----|-----| -| rustnzbd | git | v0.1.2 | -| Arz | git | v0.1.1 | -| NGMS | vendored (path) | — | - -### Depends On - -- **nzb-core** (git, v0.1.1) — job models and config -- **rust-par2** (crates.io, v0.1) — PAR2 verify/repair engine - -## Public API - -```rust -pub mod detect; // File type detection (par2, RAR, 7z, ZIP) -pub mod par2; // PAR2 operations -pub mod pipeline; // Main orchestrator -pub mod unpack; // Archive extraction (RAR, 7z, ZIP) - -pub use detect::ArchiveType; -pub use pipeline::{PostProcConfig, PostProcResult, run_pipeline}; -``` - -### Key Types - -- **`run_pipeline(job_dir, config) -> PostProcResult`** — main entry point. Runs: Verify -> Repair -> Extract -> Cleanup. -- **`PostProcConfig`** — cleanup_after_extract, output_dir, articles_failed (0 = skip verify optimization) -- **`PostProcResult`** — success, stages: Vec, error -- **`ArchiveType`** — Rar, SevenZip, Zip - -### Pipeline Stages - -1. **Verify** — PAR2 integrity check (skipped if articles_failed == 0) -2. **Repair** — PAR2 Reed-Solomon repair if damaged -3. **Extract** — unpack RAR/7z/ZIP archives -4. **Cleanup** — remove par2/archive files after successful extraction - -### Detection Functions - -- `find_par2_files(dir)` — index files first, then volumes -- `find_archives(dir)` — RAR, 7z, ZIP detection -- `find_cleanup_files(dir)` — candidates for removal - -## Key Dependencies - -- nzb-core, rust-par2 -- walkdir (recursive traversal) -- zip (ZIP extraction) -- tokio (spawn_blocking for PAR2 — VerifyResult is !Send) diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 7e4acd6..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1939 +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 = "aes" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" -dependencies = [ - "cipher", - "cpubits", - "cpufeatures 0.3.0", -] - -[[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 = "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 = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", - "zeroize", -] - -[[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 = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "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 = "cipher" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" -dependencies = [ - "crypto-common 0.2.2", - "inout", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -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-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[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 = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[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 = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", - "ctutils", - "zeroize", -] - -[[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", - "zlib-rs", -] - -[[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", - "js-sys", - "libc", - "r-efi 6.0.0", - "wasm-bindgen", -] - -[[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 = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[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 = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[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 = "libbz2-rs-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" - -[[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 = "lzma-rust2" -version = "0.16.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" -dependencies = [ - "sha2 0.11.0", -] - -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - -[[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-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b482d1318e4fa6a7c54be941b5521e50bc8f71eb82de0fe59213bffa5161386e" -dependencies = [ - "anyhow", - "chrono", - "nzb-nntp", - "parking_lot", - "quick-xml", - "rusqlite", - "serde", - "serde_json", - "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 0.10.9", - "socket2 0.5.10", - "thiserror 2.0.19", - "tokio", - "tokio-rustls", - "tokio-socks", - "tracing", - "uuid", - "webpki-roots", -] - -[[package]] -name = "nzb-postproc" -version = "0.2.7" -dependencies = [ - "anyhow", - "nzb-core", - "opentelemetry", - "rust-par2", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tracing", - "walkdir", - "zip", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opentelemetry" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "236e667b670a5cdf90c258f5a55794ec5ac5027e960c224bff8367a59e1e6426" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.19", - "tracing", -] - -[[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 = "pbkdf2" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" -dependencies = [ - "digest 0.11.3", - "hmac", -] - -[[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 = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppmd-rust" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" - -[[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 = "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-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 = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[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 = "rust-par2" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e385f48537be83896aa33de9bc92e8d0d40990701d0670fb043ac2ad6da54b65" -dependencies = [ - "crc32fast", - "md-5", - "rayon", - "thiserror 2.0.19", - "tracing", -] - -[[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 = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[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 = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[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 = "time" -version = "0.3.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" -dependencies = [ - "deranged", - "js-sys", - "num-conv", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[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", - "signal-hook-registry", - "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 = "typed-path" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[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 = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[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-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-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 = "zip" -version = "8.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" -dependencies = [ - "aes", - "bzip2", - "constant_time_eq", - "crc32fast", - "deflate64", - "flate2", - "getrandom 0.4.3", - "hmac", - "indexmap", - "lzma-rust2", - "memchr", - "pbkdf2", - "ppmd-rust", - "sha1", - "time", - "typed-path", - "zeroize", - "zopfli", - "zstd", -] - -[[package]] -name = "zlib-rs" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml index aaf87e6..c42a60e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-postproc" -version = "0.2.7" +version = "0.2.8" edition = "2024" description = "Post-processing pipeline: PAR2 verify/repair, archive extraction" license = "MIT" @@ -12,18 +12,25 @@ default = [] groups-db = ["nzb-core/groups-db"] [dependencies] -nzb-core = { version = "0.2.17" } +nzb-core = { version = "0.2.18", path = "../nzb-core" } tokio = { version = "1", features = ["rt", "sync", "process"] } tracing = "0.1" -opentelemetry = "0.28" +opentelemetry.workspace = true anyhow = "1" thiserror = "2" rust-par2 = "0.1" walkdir = "2" zip = "8" +tar.workspace = true [dev-dependencies] tempfile = "3" +# Integration tests synthesise real PAR2 index files (see +# `tests/support/par2_fixture.rs`), which requires writing spec-correct +# packet MD5s. Version tracks what `rust-par2` itself parses with. +md-5 = "0.11" +crc32fast = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [lints.clippy] all = { level = "warn", priority = -1 } 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/detect.rs b/src/detect.rs index 5d842d5..a59ad3c 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -1,12 +1,60 @@ //! File detection helpers for post-processing. //! //! Scans a completed download directory to find par2 files, RAR archives, -//! 7z archives, ZIP archives, and cleanup candidates. +//! 7z archives, TAR archives, ZIP archives, and cleanup candidates. +use std::collections::BTreeMap; +use std::io::Read; use std::path::{Path, PathBuf}; use walkdir::WalkDir; +/// RAR 4.x volume signature. +const RAR4_SIGNATURE: &[u8] = b"Rar!\x1a\x07\x00"; +/// RAR 5.x volume signature. +const RAR5_SIGNATURE: &[u8] = b"Rar!\x1a\x07\x01\x00"; + +/// Returns true if the file begins with a RAR volume signature. +/// +/// Obfuscated posts strip every naming cue, so content is the only reliable +/// evidence that a `.NN` file is an archive volume. Extension matching +/// alone would misclassify unrelated numeric-suffixed files. +pub fn has_rar_signature(path: &Path) -> bool { + let Ok(mut file) = std::fs::File::open(path) else { + return false; + }; + // Read up to the longest signature; short files simply cannot match. + let mut header = [0u8; RAR5_SIGNATURE.len()]; + let mut filled = 0; + while filled < header.len() { + match file.read(&mut header[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(_) => return false, + } + } + let head = &header[..filled]; + head.starts_with(RAR4_SIGNATURE) || head.starts_with(RAR5_SIGNATURE) +} + +/// Split a bare numeric extension: `"cfd4be79….45"` → `("cfd4be79…", 45)`. +/// +/// Returns `None` for anything with a non-numeric extension, and for split 7z +/// volumes (`archive.7z.001`) — those are numeric too, but they belong to the +/// 7z path and must not be reclassified as RAR. +fn split_numeric_volume(filename: &str) -> Option<(&str, u32)> { + let dot = filename.rfind('.')?; + let ext = &filename[dot + 1..]; + if ext.is_empty() || !ext.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let stem = &filename[..dot]; + if stem.to_ascii_lowercase().ends_with(".7z") { + return None; + } + ext.parse::().ok().map(|num| (stem, num)) +} + /// Parsed RAR volume information: set name and volume number. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RarVolumeInfo { @@ -77,11 +125,38 @@ pub fn parse_rar_volume(filename: &str) -> Option { None } +/// Parse a RAR volume from a file on disk, falling back to content inspection. +/// +/// [`parse_rar_volume`] can only judge names, so it cannot recognise an +/// obfuscated volume like `cfd4be79….45`. This variant additionally accepts a +/// bare numeric extension when the file actually begins with a RAR signature — +/// evidence a filename cannot provide. Prefer it wherever a path is available. +/// +/// The volume number for an obfuscated set is the numeric extension itself, so +/// volumes order correctly relative to one another within the set. It is not +/// comparable with the numbering [`parse_rar_volume`] assigns to conventional +/// sets, which is anchored to `.rar` = 0. +pub fn parse_rar_volume_at(path: &Path) -> Option { + let name = path.file_name().and_then(|n| n.to_str())?; + if let Some(info) = parse_rar_volume(name) { + return Some(info); + } + let (set_name, volume_number) = split_numeric_volume(name)?; + if !has_rar_signature(path) { + return None; + } + Some(RarVolumeInfo { + set_name: set_name.to_string(), + volume_number, + }) +} + /// The type of archive detected. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArchiveType { Rar, SevenZip, + Tar, Zip, } @@ -90,6 +165,7 @@ impl std::fmt::Display for ArchiveType { match self { Self::Rar => write!(f, "RAR"), Self::SevenZip => write!(f, "7z"), + Self::Tar => write!(f, "TAR"), Self::Zip => write!(f, "ZIP"), } } @@ -159,6 +235,8 @@ fn is_par2_volume(name_lower: &str) -> bool { /// `unrar x`). pub fn find_rar_files(dir: &Path) -> Vec { let mut first_volumes: Vec = Vec::new(); + // Obfuscated sets keyed by set name → (lowest volume seen, its path). + let mut numeric_sets: BTreeMap = BTreeMap::new(); for entry in WalkDir::new(dir).into_iter().flatten() { let path = entry.path(); @@ -191,11 +269,34 @@ pub fn find_rar_files(dir: &Path) -> Vec { } // Plain .rar with no .partNNN — this is the first volume in old-style first_volumes.push(path.to_path_buf()); + continue; } // Old-style: .r00, .r01, etc. — we do NOT add these; the .rar file // is the first volume in old-style sets. + if parse_rar_volume(&name_lower).is_some() { + continue; + } + + // Obfuscated set: `.NN` with no recognisable extension. Only + // files that actually begin with a RAR signature qualify, so unrelated + // numeric-suffixed files (`Concert.Recording.1987`) are left alone. + // The lowest-numbered volume is treated as the one to hand to unrar. + if let Some((set_name, volume_number)) = split_numeric_volume(&name_lower) + && has_rar_signature(path) + { + numeric_sets + .entry(set_name.to_string()) + .and_modify(|(lowest, lowest_path)| { + if volume_number < *lowest { + *lowest = volume_number; + *lowest_path = path.to_path_buf(); + } + }) + .or_insert_with(|| (volume_number, path.to_path_buf())); + } } + first_volumes.extend(numeric_sets.into_values().map(|(_, path)| path)); first_volumes.sort(); first_volumes } @@ -210,7 +311,7 @@ pub fn find_archives(dir: &Path) -> Vec<(ArchiveType, PathBuf)> { archives.push((ArchiveType::Rar, path)); } - // 7z (including split volumes), and ZIP + // 7z (including split volumes), TAR, and ZIP for entry in WalkDir::new(dir).into_iter().flatten() { let path = entry.path(); if !path.is_file() { @@ -226,6 +327,8 @@ pub fn find_archives(dir: &Path) -> Vec<(ArchiveType, PathBuf)> { } else if is_split_7z_first_volume(&name) { // Split 7z: .7z.001 is the first volume — 7z handles the rest archives.push((ArchiveType::SevenZip, path.to_path_buf())); + } else if name.ends_with(".tar") { + archives.push((ArchiveType::Tar, path.to_path_buf())); } else if name.ends_with(".zip") { archives.push((ArchiveType::Zip, path.to_path_buf())); } @@ -251,7 +354,7 @@ pub fn find_cleanup_files(dir: &Path) -> Vec { None => continue, }; - if is_cleanup_candidate(&name) { + if is_cleanup_candidate_at(path, &name) { cleanup.push(path.to_path_buf()); } } @@ -273,7 +376,7 @@ pub fn has_usable_output(dir: &Path) -> std::io::Result { let Some(name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; - if !is_cleanup_candidate(&name.to_ascii_lowercase()) { + if !is_cleanup_candidate_at(path, &name.to_ascii_lowercase()) { return Ok(true); } } @@ -307,11 +410,37 @@ fn is_split_7z_volume(name_lower: &str) -> bool { false } -/// Determine whether a file (by its lowercased name) is safe to clean up +/// Determine whether a file on disk is safe to clean up after successful +/// extraction, using its name and — where the name is uninformative — its +/// content. +/// +/// `name_lower` must be the lowercased file name of `path`. +/// +/// An obfuscated `.NN` volume is indistinguishable by name from an +/// ordinary file that happens to end in digits, so the RAR signature is what +/// separates junk to delete from payload to keep. Getting this wrong in either +/// direction is costly: treating payload as junk deletes it, and treating an +/// archive volume as payload lets a job report Completed with nothing usable +/// in it (issue #87). +fn is_cleanup_candidate_at(path: &Path, name_lower: &str) -> bool { + if is_cleanup_candidate(name_lower) { + return true; + } + split_numeric_volume(name_lower).is_some() && has_rar_signature(path) +} + +/// Determine whether a file (by its lowercased name alone) is safe to clean up /// after successful extraction. +/// +/// Name-only: prefer [`is_cleanup_candidate_at`] wherever a path is available, +/// so obfuscated volumes are caught too. fn is_cleanup_candidate(name: &str) -> bool { // Par2 files: .par2 - if name.ends_with(".par2") || name.ends_with(".zip") || name.ends_with(".7z") { + if name.ends_with(".par2") + || name.ends_with(".zip") + || name.ends_with(".7z") + || name.ends_with(".tar") + { return true; } diff --git a/src/lib.rs b/src/lib.rs index 5d034c8..b1ece59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,21 +1,29 @@ -//! Post-processing pipeline: par2 verify/repair, RAR/7z/ZIP extraction, cleanup. +//! Post-processing pipeline: par2 verify/repair, RAR/7z/TAR/ZIP extraction, cleanup. //! //! This crate contains: -//! - `detect` — File detection helpers (par2, RAR, 7z, ZIP, cleanup candidates) +//! - `detect` — File detection helpers (par2, RAR, 7z, TAR, ZIP, cleanup candidates) //! - `par2` — Native PAR2 verify/repair via `rust-par2` -//! - `unpack` — RAR extraction (unrar), 7z (7z binary), ZIP (zip crate) +//! - `unpack` — RAR/7z extraction (system tools), TAR/ZIP (native crates) //! - `pipeline` — Orchestrate: verify -> repair -> extract -> cleanup pub mod detect; pub mod par2; pub mod pipeline; +pub mod resources; pub mod unpack; // Re-export nzb-core (and transitively nzb-nntp) so consumers only // need nzb-postproc as a single dependency. pub use nzb_core; -pub use detect::{ArchiveType, RarVolumeInfo, has_usable_output, parse_rar_volume}; +pub use detect::{ + ArchiveType, RarVolumeInfo, has_rar_signature, has_usable_output, parse_rar_volume, + parse_rar_volume_at, +}; pub use par2::recovery_can_cover; -pub use pipeline::{PostProcConfig, PostProcResult, run_pipeline}; -pub use unpack::find_unrar; +pub use pipeline::{ + PostProcConfig, PostProcResult, run_pipeline, run_pipeline_with_cleanup, + run_pipeline_with_resources, +}; +pub use resources::{PostProcLimits, PostProcResourcePool, PostProcResourceSnapshot}; +pub use unpack::{extract_tar, find_unrar}; diff --git a/src/pipeline.rs b/src/pipeline.rs index 06d9265..17a5e56 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Instant; use nzb_core::models::{StageResult, StageStatus}; @@ -15,7 +16,8 @@ use tracing::{debug, error, info, warn}; use crate::detect::{ArchiveType, find_archives, find_cleanup_files, find_par2_files}; use crate::par2::par2_repair; -use crate::unpack::{extract_7z, extract_rar, extract_zip}; +use crate::resources::PostProcResourcePool; +use crate::unpack::{extract_7z, extract_rar, extract_tar, extract_zip}; fn increment_counter(name: &'static str) { opentelemetry::global::meter_provider() @@ -101,6 +103,30 @@ impl Default for PostProcConfig { /// 3. **Extract** — unpack RAR, 7z, ZIP archives /// 4. **Cleanup** — remove archive/par2 files (if configured) pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult { + run_pipeline_with_resources(job_dir, config, None).await +} + +/// Run the pipeline under optional shared stage-specific resource gates. +/// +/// This additive entry point keeps [`PostProcConfig`] source-compatible for +/// library consumers while allowing applications to coordinate independent +/// jobs through one [`PostProcResourcePool`]. +pub async fn run_pipeline_with_resources( + job_dir: &Path, + config: &PostProcConfig, + resources: Option<&Arc>, +) -> PostProcResult { + run_pipeline_with_cleanup(job_dir, config, resources, &[], &[]).await +} + +/// Run the pipeline with optional category cleanup rules. +pub async fn run_pipeline_with_cleanup( + job_dir: &Path, + config: &PostProcConfig, + resources: Option<&Arc>, + cleanup_patterns: &[String], + unwanted_extensions: &[String], +) -> PostProcResult { let mut stages: Vec = Vec::new(); let mut pipeline_ok = true; @@ -145,15 +171,42 @@ pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcRe }); } } else if config.articles_failed == 0 { + // Files are known-good from CRC checks during yEnc decode, so the + // expensive MD5 verification pass is skipped. + // + // PAR2-guided deobfuscation still has to run. Obfuscated posts arrive + // with meaningless filenames whether or not an article failed, and the + // PAR2 metadata is the only record of the real names. While this + // rename lived inside the verify branch below, a *clean* download of + // an obfuscated post was never deobfuscated: no archive was found, the + // Extract stage reported "No archives found", and the job completed + // with raw volumes on disk. A damaged download self-healed; a healthy + // one did not (issue #87). info!("Skipping PAR2 verification — zero article failures (CRC-verified)"); + let start = Instant::now(); + let message = match rust_par2::parse(&par2_files[0]) { + Ok(file_set) => { + rename_to_par2_names(&file_set, job_dir); + "Skipped — zero article failures (PAR2-guided rename applied)".to_string() + } + Err(e) => { + debug!(error = %e, "PAR2 parse failed; skipping PAR2-guided deobfuscation"); + format!("Skipped — zero article failures (PAR2 parse failed: {e})") + } + }; stages.push(StageResult { name: "Verify".to_string(), status: StageStatus::Skipped, - message: Some("Skipped — zero article failures".to_string()), - duration_secs: 0.0, + message: Some(message), + duration_secs: start.elapsed().as_secs_f64(), }); } else { let verify_start = Instant::now(); + let _repair_permit = if let Some(resources) = resources { + Some(resources.acquire_repair().await) + } else { + None + }; let index_par2 = par2_files[0].clone(); match rust_par2::parse(&index_par2) { @@ -162,7 +215,9 @@ pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcRe // PAR2 expected names (common with obfuscated posts where // NZB subjects have readable names but PAR2 references // the original obfuscated filenames), rename them using - // MD5-16k hash matching before verification runs. + // MD5-16k hash matching before verification runs. The + // zero-failure branch above runs this too — verification is + // skipped there, but deobfuscation must not be. rename_to_par2_names(&file_set, job_dir); // Run verify (and repair if needed) in a single spawn_blocking call. @@ -364,6 +419,11 @@ pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcRe } else { job_dir }; + let _extract_permit = if let Some(resources) = resources { + Some(resources.acquire_extract().await) + } else { + None + }; let (result, processed_archives) = run_extract_stage( source_dir, output_dir, @@ -385,7 +445,14 @@ pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcRe // Stage 4: Cleanup // ------------------------------------------------------------------ if pipeline_ok && config.cleanup_after_extract { - let result = run_cleanup_stage(job_dir, &extracted_archives); + let cleanup_root = config.output_dir.as_deref().unwrap_or(job_dir); + let result = run_cleanup_stage_with_rules( + job_dir, + cleanup_root, + &extracted_archives, + cleanup_patterns, + unwanted_extensions, + ); stages.push(result); } @@ -591,6 +658,7 @@ async fn run_extract_stage( let result = match archive_type { ArchiveType::Rar => extract_rar(path, output_dir, password).await, ArchiveType::SevenZip => extract_7z(path, output_dir, password).await, + ArchiveType::Tar => extract_tar(path, output_dir).await, ArchiveType::Zip => extract_zip(path, output_dir).await, }; @@ -663,15 +731,108 @@ async fn run_extract_stage( ) } +#[cfg(test)] fn run_cleanup_stage(job_dir: &Path, extracted_archives: &[PathBuf]) -> StageResult { + run_cleanup_stage_with_rules(job_dir, job_dir, extracted_archives, &[], &[]) +} + +fn wildcard_match(pattern: &str, value: &str) -> bool { + let (mut pattern_index, mut value_index) = (0usize, 0usize); + let mut star = None; + let mut star_value = 0usize; + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + + while value_index < value.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == value[value_index] || pattern[pattern_index] == b'?') + { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star = Some(pattern_index); + pattern_index += 1; + star_value = value_index; + } else if let Some(star_index) = star { + pattern_index = star_index + 1; + star_value += 1; + value_index = star_value; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} + +fn run_cleanup_stage_with_rules( + job_dir: &Path, + cleanup_root: &Path, + extracted_archives: &[PathBuf], + cleanup_patterns: &[String], + unwanted_extensions: &[String], +) -> StageResult { let start = Instant::now(); let mut files = find_cleanup_files(job_dir); files.extend( extracted_archives .iter() - .filter(|path| path.is_file()) + .filter(|path| { + std::fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_file()) + .unwrap_or(false) + }) .cloned(), ); + + let normalized_extensions: Vec = unwanted_extensions + .iter() + .map(|extension| { + let extension = extension.trim().to_ascii_lowercase(); + if extension.starts_with('.') { + extension + } else { + format!(".{extension}") + } + }) + .filter(|extension| extension.len() > 1) + .collect(); + let patterns: Vec = cleanup_patterns + .iter() + .map(|pattern| pattern.replace('\\', "/").to_ascii_lowercase()) + .filter(|pattern| !pattern.is_empty()) + .collect(); + for root in [job_dir, cleanup_root] { + for entry in walkdir::WalkDir::new(root).into_iter().flatten() { + let path = entry.path(); + let Ok(metadata) = std::fs::symlink_metadata(path) else { + continue; + }; + if !metadata.file_type().is_file() { + continue; + } + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + let filename = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + if normalized_extensions + .iter() + .any(|extension| filename.to_ascii_lowercase().ends_with(extension)) + || patterns.iter().any(|pattern| { + wildcard_match(pattern, &relative.to_ascii_lowercase()) + || wildcard_match(pattern, &filename.to_ascii_lowercase()) + }) + { + files.push(path.to_path_buf()); + } + } + } files.sort(); files.dedup(); @@ -688,6 +849,15 @@ fn run_cleanup_stage(job_dir: &Path, extracted_archives: &[PathBuf]) -> StageRes let mut errors = 0u32; for path in &files { + let under_allowed_root = path.starts_with(job_dir) || path.starts_with(cleanup_root); + let is_regular_file = std::fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_file()) + .unwrap_or(false); + if !under_allowed_root || !is_regular_file { + warn!(file = %path.display(), "Skipping cleanup path outside job roots or through a link"); + errors += 1; + continue; + } match std::fs::remove_file(path) { Ok(()) => { removed += 1; diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..a750c1e --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,245 @@ +//! Resource gates shared by independent post-processing jobs. +//! +//! The limits are deliberately expressed as worker counts. PAR2 work is the +//! CPU- and memory-heavy stage while extraction is primarily constrained by +//! disk throughput. Bounding each stage separately lets one job extract while +//! another repairs without allowing either workload to grow without bound. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +/// Concurrent post-processing worker limits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PostProcLimits { + /// Jobs admitted to the post-processing pipeline at once. + pub pipelines: usize, + /// Concurrent PAR2 verify/repair workers. + pub repair: usize, + /// Concurrent archive extraction workers. + pub extract: usize, +} + +impl Default for PostProcLimits { + fn default() -> Self { + Self { + pipelines: 2, + repair: 1, + extract: 1, + } + } +} + +impl PostProcLimits { + fn normalized(self) -> Self { + Self { + pipelines: self.pipelines.max(1), + repair: self.repair.max(1), + extract: self.extract.max(1), + } + } +} + +/// Current and lifetime-high concurrent stage usage. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PostProcResourceSnapshot { + pub active_pipelines: usize, + pub active_repairs: usize, + pub active_extractions: usize, + pub peak_pipelines: usize, + pub peak_repairs: usize, + pub peak_extractions: usize, +} + +#[derive(Debug, Default)] +struct Usage { + active: AtomicUsize, + peak: AtomicUsize, +} + +impl Usage { + fn acquired(&self) { + let active = self.active.fetch_add(1, Ordering::AcqRel) + 1; + self.peak.fetch_max(active, Ordering::Relaxed); + } + + fn released(&self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} + +/// Shared stage-specific semaphore set for the post-processing pipeline. +#[derive(Debug)] +pub struct PostProcResourcePool { + limits: PostProcLimits, + pipelines: Arc, + repairs: Arc, + extractions: Arc, + pipeline_usage: Arc, + repair_usage: Arc, + extraction_usage: Arc, +} + +impl PostProcResourcePool { + pub fn new(limits: PostProcLimits) -> Arc { + let limits = limits.normalized(); + Arc::new(Self { + limits, + pipelines: Arc::new(Semaphore::new(limits.pipelines)), + repairs: Arc::new(Semaphore::new(limits.repair)), + extractions: Arc::new(Semaphore::new(limits.extract)), + pipeline_usage: Arc::new(Usage::default()), + repair_usage: Arc::new(Usage::default()), + extraction_usage: Arc::new(Usage::default()), + }) + } + + pub fn limits(&self) -> PostProcLimits { + self.limits + } + + pub async fn acquire_pipeline(self: &Arc) -> PostProcPermit { + Self::acquire(&self.pipelines, &self.pipeline_usage).await + } + + pub(crate) async fn acquire_repair(self: &Arc) -> PostProcPermit { + Self::acquire(&self.repairs, &self.repair_usage).await + } + + pub(crate) async fn acquire_extract(self: &Arc) -> PostProcPermit { + Self::acquire(&self.extractions, &self.extraction_usage).await + } + + async fn acquire(semaphore: &Arc, usage: &Arc) -> PostProcPermit { + let permit = Arc::clone(semaphore) + .acquire_owned() + .await + .expect("post-processing resource semaphore must remain open"); + usage.acquired(); + PostProcPermit { + _permit: permit, + usage: Arc::clone(usage), + } + } + + pub fn snapshot(&self) -> PostProcResourceSnapshot { + PostProcResourceSnapshot { + active_pipelines: self.pipeline_usage.active.load(Ordering::Relaxed), + active_repairs: self.repair_usage.active.load(Ordering::Relaxed), + active_extractions: self.extraction_usage.active.load(Ordering::Relaxed), + peak_pipelines: self.pipeline_usage.peak.load(Ordering::Relaxed), + peak_repairs: self.repair_usage.peak.load(Ordering::Relaxed), + peak_extractions: self.extraction_usage.peak.load(Ordering::Relaxed), + } + } +} + +/// RAII permit which releases both the semaphore slot and usage counter. +#[derive(Debug)] +pub struct PostProcPermit { + _permit: OwnedSemaphorePermit, + usage: Arc, +} + +impl Drop for PostProcPermit { + fn drop(&mut self) { + self.usage.released(); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[tokio::test] + async fn different_stages_can_overlap_but_same_stage_respects_its_limit() { + let pool = PostProcResourcePool::new(PostProcLimits { + pipelines: 2, + repair: 1, + extract: 1, + }); + let _pipeline_a = pool.acquire_pipeline().await; + let _pipeline_b = pool.acquire_pipeline().await; + let repair_a = pool.acquire_repair().await; + let _extract_b = pool.acquire_extract().await; + + let overlap = pool.snapshot(); + assert_eq!(overlap.active_pipelines, 2); + assert_eq!(overlap.active_repairs, 1); + assert_eq!(overlap.active_extractions, 1); + + let waiting_pool = Arc::clone(&pool); + let mut waiting_repair = tokio::spawn(async move { waiting_pool.acquire_repair().await }); + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut waiting_repair) + .await + .is_err(), + "a second repair must wait for the configured repair worker" + ); + + drop(repair_a); + let repair_b = tokio::time::timeout(Duration::from_secs(1), waiting_repair) + .await + .expect("waiting repair should be admitted") + .expect("waiting repair task should not panic"); + drop(repair_b); + + let snapshot = pool.snapshot(); + assert_eq!(snapshot.peak_pipelines, 2); + assert_eq!(snapshot.peak_repairs, 1); + assert_eq!(snapshot.peak_extractions, 1); + } + + #[tokio::test] + async fn zero_limits_are_safely_normalized_to_one_worker() { + let pool = PostProcResourcePool::new(PostProcLimits { + pipelines: 0, + repair: 0, + extract: 0, + }); + assert_eq!( + pool.limits(), + PostProcLimits { + pipelines: 1, + repair: 1, + extract: 1, + } + ); + } + + #[tokio::test] + async fn stress_many_jobs_never_exceeds_stage_worker_limits() { + let pool = PostProcResourcePool::new(PostProcLimits { + pipelines: 3, + repair: 2, + extract: 1, + }); + let mut jobs = Vec::new(); + for index in 0..24 { + let resources = Arc::clone(&pool); + jobs.push(tokio::spawn(async move { + let _pipeline = resources.acquire_pipeline().await; + if index % 2 == 0 { + let _repair = resources.acquire_repair().await; + tokio::task::yield_now().await; + } + let _extract = resources.acquire_extract().await; + tokio::task::yield_now().await; + })); + } + for job in jobs { + job.await.expect("stress job should not panic"); + } + + let snapshot = pool.snapshot(); + assert!(snapshot.peak_pipelines <= 3, "{snapshot:?}"); + assert!(snapshot.peak_repairs <= 2, "{snapshot:?}"); + assert!(snapshot.peak_extractions <= 1, "{snapshot:?}"); + assert_eq!(snapshot.active_pipelines, 0); + assert_eq!(snapshot.active_repairs, 0); + assert_eq!(snapshot.active_extractions, 0); + } +} diff --git a/src/unpack.rs b/src/unpack.rs index 8ab633e..e01fba7 100644 --- a/src/unpack.rs +++ b/src/unpack.rs @@ -1,10 +1,11 @@ -//! Archive extraction: RAR, 7z, ZIP. +//! Archive extraction: RAR, 7z, TAR, ZIP. //! //! - RAR: Shell out to `unrar` binary //! - 7z: Shell out to `7z`/`7zz`/`7za` binary //! - ZIP: Uses std::fs + zip crate use std::collections::HashSet; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -107,6 +108,36 @@ fn output_files(root: &Path) -> std::io::Result> { Ok(files) } +/// Reject links and non-directory path components left by an external +/// extractor. Native formats are checked before each write; this is the +/// equivalent postcondition for unrar/7z, whose archive member lists are not +/// exposed through a stable API. +fn validate_extraction_tree(root: &Path) -> anyhow::Result<()> { + let mut directories = vec![root.to_path_buf()]; + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_symlink() { + anyhow::bail!( + "archive extraction produced a symbolic link `{}`", + path.display() + ); + } + if file_type.is_dir() { + directories.push(path); + } else if !file_type.is_file() { + anyhow::bail!( + "archive extraction produced unsupported path `{}`", + path.display() + ); + } + } + } + Ok(()) +} + fn newly_extracted_files( output_dir: &Path, before: &HashSet, @@ -119,6 +150,37 @@ fn newly_extracted_files( Ok(files) } +fn safe_archive_output_path(root: &Path, name: &str, kind: &str) -> anyhow::Result { + nzb_core::path::safe_join(root, name) + .ok_or_else(|| anyhow::anyhow!("{kind} archive contains unsafe path `{name}`")) +} + +fn reject_symlinked_path(root: &Path, path: &Path) -> anyhow::Result<()> { + let relative = path + .strip_prefix(root) + .map_err(|_| anyhow::anyhow!("archive path is outside extraction directory"))?; + let mut current = root.to_path_buf(); + if let Ok(metadata) = std::fs::symlink_metadata(¤t) + && metadata.file_type().is_symlink() + { + anyhow::bail!("archive extraction directory is a symbolic link"); + } + + for component in relative.components() { + current.push(component.as_os_str()); + let Ok(metadata) = std::fs::symlink_metadata(¤t) else { + continue; + }; + if metadata.file_type().is_symlink() { + anyhow::bail!("archive path crosses a symbolic link"); + } + if current != path && !metadata.is_dir() { + anyhow::bail!("archive path crosses a non-directory"); + } + } + Ok(()) +} + /// Extract RAR archives in a directory. /// /// If `password` is `Some`, it is passed to the extractor (`-p` for unrar, @@ -171,6 +233,10 @@ pub async fn extract_rar( let combined = format!("{stdout}\n{stderr}"); let success = output.status.success(); + if success { + validate_extraction_tree(output_dir)?; + } + if !success { // Detect password-protected archives (unrar exit code 255 + password prompt) let is_encrypted = combined.contains("Enter password") @@ -239,6 +305,10 @@ pub async fn extract_7z( let combined = format!("{stdout}\n{stderr}"); let success = output.status.success(); + if success { + validate_extraction_tree(output_dir)?; + } + if !success { let is_encrypted = SEVENZ_PASSWORD_PATTERNS .iter() @@ -281,14 +351,32 @@ pub async fn extract_zip(zip_file: &Path, output_dir: &Path) -> anyhow::Result anyhow::Result anyhow::Result { + info!(file = %tar_file.display(), dest = %output_dir.display(), "Extracting TAR"); + let tar_path = tar_file.to_path_buf(); + let out_path = output_dir.to_path_buf(); + tokio::task::spawn_blocking(move || -> anyhow::Result { + let file = std::fs::File::open(&tar_path)?; + let mut archive = tar::Archive::new(file); + std::fs::create_dir_all(&out_path)?; + let mut extracted = Vec::new(); + let mut output_paths = HashSet::new(); + + for entry in archive.entries()? { + let mut entry = entry?; + let entry_path = entry.path()?.to_string_lossy().into_owned(); + let entry_type = entry.header().entry_type(); + if entry_type.is_symlink() { + anyhow::bail!("TAR archive contains unsupported symbolic link `{entry_path}`"); + } + if entry_type.is_hard_link() { + anyhow::bail!("TAR archive contains unsupported hard link `{entry_path}`"); + } + + let outpath = safe_archive_output_path(&out_path, &entry_path, "TAR")?; + if !output_paths.insert(outpath.clone()) { + anyhow::bail!("TAR archive contains duplicate output path `{entry_path}`"); + } + + if entry_type.is_dir() { + reject_symlinked_path(&out_path, &outpath)?; + std::fs::create_dir_all(&outpath)?; + continue; + } + if !entry_type.is_file() { + anyhow::bail!("TAR archive contains unsupported entry `{entry_path}`"); + } + + reject_symlinked_path(&out_path, &outpath)?; + if let Some(parent) = outpath.parent() { + std::fs::create_dir_all(parent)?; + } + let mut outfile = std::fs::File::create(&outpath)?; + std::io::copy(&mut entry, &mut outfile)?; + outfile.flush()?; + extracted.push(outpath.to_string_lossy().into_owned()); + } + + Ok(UnpackResult { + success: true, + files_extracted: extracted, + output: String::new(), + error_output: String::new(), + }) + }) + .await? +} + pub fn find_unrar() -> Option { for name in &["unrar", "unrar-free", "rar"] { if which_exists(name) { @@ -343,6 +491,7 @@ fn which_exists(name: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use std::fs; use std::io::Write; #[tokio::test] @@ -378,6 +527,224 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_extract_zip_rejects_duplicate_output_paths() { + let dir = tempfile::tempdir().unwrap(); + let zip_path = dir.path().join("duplicate.zip"); + let out_dir = dir.path().join("out"); + { + let file = std::fs::File::create(&zip_path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + writer.add_directory("same.txt/", options).unwrap(); + writer.start_file("same.txt", options).unwrap(); + writer.write_all(b"second").unwrap(); + writer.finish().unwrap(); + } + + let result = extract_zip(&zip_path, &out_dir).await; + let error = result.unwrap_err().to_string(); + assert!(error.contains("duplicate output path"), "{error}"); + } + + #[tokio::test] + async fn test_extract_zip_rejects_parent_and_absolute_paths() { + for (index, name) in [ + "../../outside.txt", + "/absolute.txt", + r"..\..\outside.txt", + r"C:\outside.txt", + ] + .iter() + .enumerate() + { + let dir = tempfile::tempdir().unwrap(); + let zip_path = dir.path().join(format!("unsafe-{index}.zip")); + let out_dir = dir.path().join("out"); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"outside").unwrap(); + writer.finish().unwrap(); + + let result = extract_zip(&zip_path, &out_dir).await; + let error = result.unwrap_err().to_string(); + assert!(error.contains("unsafe path"), "{error}"); + assert!(!dir.path().join("outside.txt").exists()); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_zip_rejects_preexisting_symlink_ancestors() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let zip_path = dir.path().join("symlink-ancestor.zip"); + let out_dir = dir.path().join("out"); + let outside_dir = dir.path().join("outside"); + std::fs::create_dir_all(&out_dir).unwrap(); + std::fs::create_dir_all(&outside_dir).unwrap(); + symlink(&outside_dir, out_dir.join("link")).unwrap(); + + let file = std::fs::File::create(&zip_path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file("link/escaped.txt", zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"outside").unwrap(); + writer.finish().unwrap(); + + let result = extract_zip(&zip_path, &out_dir).await; + let error = result.unwrap_err().to_string(); + assert!(error.contains("symbolic link"), "{error}"); + assert!(!outside_dir.join("escaped.txt").exists()); + } + + #[tokio::test] + async fn test_extract_zip_rejects_symbolic_links() { + let dir = tempfile::tempdir().unwrap(); + let zip_path = dir.path().join("symlink.zip"); + let out_dir = dir.path().join("out"); + { + let file = std::fs::File::create(&zip_path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + writer + .add_symlink( + "link", + "outside.txt", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + writer.finish().unwrap(); + } + + let result = extract_zip(&zip_path, &out_dir).await; + let error = result.unwrap_err().to_string(); + assert!(error.contains("symbolic link"), "{error}"); + } + + fn write_tar(path: &Path, entries: &[(&str, &[u8])]) { + let file = std::fs::File::create(path).unwrap(); + let mut builder = tar::Builder::new(file); + for (name, contents) in entries { + let mut header = tar::Header::new_gnu(); + header.set_path(name).unwrap(); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, *contents).unwrap(); + } + builder.finish().unwrap(); + } + + fn write_raw_tar(path: &Path, entries: &[(&str, &[u8])]) { + let file = std::fs::File::create(path).unwrap(); + let mut builder = tar::Builder::new(file); + for (name, contents) in entries { + let mut header = tar::Header::new_gnu(); + let name_bytes = name.as_bytes(); + assert!(name_bytes.len() <= 100); + header.as_mut_bytes()[..100].fill(0); + header.as_mut_bytes()[..name_bytes.len()].copy_from_slice(name_bytes); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, *contents).unwrap(); + } + builder.finish().unwrap(); + } + + #[tokio::test] + async fn test_extract_tar_valid_and_large_file() { + let dir = tempfile::tempdir().unwrap(); + let tar_path = dir.path().join("payload.tar"); + let output = dir.path().join("output"); + let large = vec![b'x'; 128 * 1024]; + write_tar( + &tar_path, + &[("nested/hello.txt", b"hello"), ("large.bin", &large)], + ); + + let result = extract_tar(&tar_path, &output).await.unwrap(); + assert!(result.success); + assert_eq!(fs::read(output.join("nested/hello.txt")).unwrap(), b"hello"); + assert_eq!( + fs::metadata(output.join("large.bin")).unwrap().len(), + large.len() as u64 + ); + } + + #[tokio::test] + async fn test_extract_tar_rejects_traversal_and_duplicate_paths() { + for (index, names) in [vec!["../../outside.txt"], vec!["same.txt", "same.txt"]] + .into_iter() + .enumerate() + { + let dir = tempfile::tempdir().unwrap(); + let tar_path = dir.path().join(format!("unsafe-{index}.tar")); + let entries: Vec<(&str, &[u8])> = names + .iter() + .map(|name| (*name, b"data".as_slice())) + .collect(); + if names.iter().any(|name| name.contains("..")) { + write_raw_tar(&tar_path, &entries); + } else { + write_tar(&tar_path, &entries); + } + let error = extract_tar(&tar_path, &dir.path().join("output")) + .await + .unwrap_err() + .to_string(); + assert!( + error.contains("unsafe path") || error.contains("duplicate output path"), + "{error}" + ); + assert!(!dir.path().join("outside.txt").exists()); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_tar_rejects_symlink_and_hardlink_entries() { + let dir = tempfile::tempdir().unwrap(); + let tar_path = dir.path().join("links.tar"); + let file = fs::File::create(&tar_path).unwrap(); + let mut builder = tar::Builder::new(file); + let mut symlink_header = tar::Header::new_gnu(); + symlink_header.set_entry_type(tar::EntryType::Symlink); + symlink_header.set_path("link").unwrap(); + symlink_header.set_link_name("outside").unwrap(); + symlink_header.set_size(0); + symlink_header.set_cksum(); + builder.append(&symlink_header, &[][..]).unwrap(); + builder.finish().unwrap(); + let error = extract_tar(&tar_path, &dir.path().join("output")) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("symbolic link"), "{error}"); + + let hardlink_path = dir.path().join("hardlink.tar"); + let file = fs::File::create(&hardlink_path).unwrap(); + let mut builder = tar::Builder::new(file); + let mut hardlink_header = tar::Header::new_gnu(); + hardlink_header.set_entry_type(tar::EntryType::Link); + hardlink_header.set_path("copy").unwrap(); + hardlink_header.set_link_name("original").unwrap(); + hardlink_header.set_size(0); + hardlink_header.set_cksum(); + builder.append(&hardlink_header, &[][..]).unwrap(); + builder.finish().unwrap(); + let error = extract_tar(&hardlink_path, &dir.path().join("hardlink-output")) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("hard link"), "{error}"); + } + #[test] fn test_unpack_result_fields() { let result = UnpackResult { diff --git a/tests/obfuscated_rar_volumes.rs b/tests/obfuscated_rar_volumes.rs new file mode 100644 index 0000000..5f29501 --- /dev/null +++ b/tests/obfuscated_rar_volumes.rs @@ -0,0 +1,303 @@ +//! Detection-layer regression suite for issue #87: obfuscated multi-volume RAR +//! sets named `<32 hex digits>.NN` were never detected, never extracted, and +//! still let the job report Completed. +//! +//! The suite is deliberately paired: +//! +//! * `neg_*` — **negative controls**. Conventional naming (`.rar`/`.r00`, +//! `.partNNN.rar`, `.7z.001`) and true payload. These passed before the fix +//! as well as after, so a `pos_*` failure is about the *name*, not the +//! plumbing. They are the guard against the fix breaking ordinary sets. +//! +//! * `pos_*` — the obfuscated `.NN` set. Each of these failed before the +//! fix, one per broken layer. +//! +//! * `guard_*` — **false-positive guards**. A naive fix ("treat any numeric +//! extension as a RAR volume") breaks these; they are why detection reads +//! the RAR signature instead of trusting the extension. +//! +//! Fixtures carry real RAR signature bytes because detection now sniffs them. + +use std::fs; +use std::path::Path; + +use nzb_postproc::ArchiveType; +use nzb_postproc::detect::{ + find_archives, find_cleanup_files, has_usable_output, parse_rar_volume, parse_rar_volume_at, +}; + +/// RAR 4.x volume signature. +const RAR4_MAGIC: &[u8] = b"Rar!\x1a\x07\x00"; +/// RAR 5.x volume signature. +const RAR5_MAGIC: &[u8] = b"Rar!\x1a\x07\x01\x00"; + +/// The obfuscated set name from the issue report. +const HASH: &str = "cfd4be79c0fb01d429c52a9f8551ee79"; + +/// Write `files` (name, contents) into a fresh temp dir. +fn make_dir(files: &[(&str, &[u8])]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (name, contents) in files { + fs::write(dir.path().join(name), contents).unwrap(); + } + dir +} + +/// A RAR volume body: signature + filler so the file is not empty. +fn rar_volume(magic: &[u8]) -> Vec { + let mut body = magic.to_vec(); + body.extend_from_slice(&[0u8; 64]); + body +} + +/// Count the RAR entries `find_archives` reported. +fn rar_archives(dir: &Path) -> Vec { + find_archives(dir) + .into_iter() + .filter(|(kind, _)| *kind == ArchiveType::Rar) + .map(|(_, path)| path.file_name().unwrap().to_string_lossy().into_owned()) + .collect() +} + +/// Cleanup candidates as bare filenames. +fn cleanup_names(dir: &Path) -> Vec { + find_cleanup_files(dir) + .into_iter() + .map(|path| path.file_name().unwrap().to_string_lossy().into_owned()) + .collect() +} + +// --------------------------------------------------------------------------- +// Negative controls — conventional naming. Must stay green. +// --------------------------------------------------------------------------- + +#[test] +fn neg_old_style_rar_set_is_detected_extracted_and_cleaned() { + let body = rar_volume(RAR4_MAGIC); + let dir = make_dir(&[ + ("movie.rar", &body), + ("movie.r00", &body), + ("movie.r01", &body), + ("movie.par2", b""), + ]); + + // Detected, and only the first volume is offered for extraction. + assert_eq!( + rar_archives(dir.path()), + vec!["movie.rar".to_string()], + "old-style set should yield exactly the first volume" + ); + + // Every volume is a cleanup candidate. + let cleanup = cleanup_names(dir.path()); + for name in ["movie.rar", "movie.r00", "movie.r01", "movie.par2"] { + assert!( + cleanup.contains(&name.to_string()), + "{name} should be cleanup" + ); + } + + // Raw archives alone are not a usable completion. + assert!( + !has_usable_output(dir.path()).unwrap(), + "a directory of raw volumes must not count as usable output" + ); + + // Volume numbering is understood. + assert_eq!(parse_rar_volume("movie.rar").unwrap().volume_number, 0); + assert_eq!(parse_rar_volume("movie.r00").unwrap().volume_number, 1); + assert_eq!(parse_rar_volume("movie.r01").unwrap().volume_number, 2); +} + +#[test] +fn neg_new_style_part_set_is_detected() { + let body = rar_volume(RAR5_MAGIC); + let dir = make_dir(&[ + ("movie.part001.rar", &body), + ("movie.part002.rar", &body), + ("movie.part003.rar", &body), + ]); + + assert_eq!( + rar_archives(dir.path()), + vec!["movie.part001.rar".to_string()], + "new-style set should yield exactly part001" + ); + assert!(!has_usable_output(dir.path()).unwrap()); + + let parsed = parse_rar_volume("movie.part002.rar").unwrap(); + assert_eq!(parsed.set_name, "movie"); + assert_eq!(parsed.volume_number, 1); +} + +#[test] +fn neg_split_7z_set_is_detected() { + let dir = make_dir(&[ + ("archive.7z.001", b"7z\xbc\xaf\x27\x1c"), + ("archive.7z.002", b"data"), + ]); + + let archives = find_archives(dir.path()); + assert_eq!(archives.len(), 1, "only the .001 volume starts extraction"); + assert_eq!(archives[0].0, ArchiveType::SevenZip); + assert!(!has_usable_output(dir.path()).unwrap()); +} + +#[test] +fn neg_real_payload_counts_as_usable_output() { + let dir = make_dir(&[("Movie.2024.1080p.mkv", b"\x1a\x45\xdf\xa3matroska")]); + assert!( + has_usable_output(dir.path()).unwrap(), + "extracted media must count as usable output" + ); + assert!( + cleanup_names(dir.path()).is_empty(), + "payload must never be a cleanup candidate" + ); +} + +// --------------------------------------------------------------------------- +// The obfuscated `.NN` set — each of these failed before the fix. +// --------------------------------------------------------------------------- + +/// Build the obfuscated set from the issue: `.45`, `.46`, `.47` + par2. +fn obfuscated_dir() -> tempfile::TempDir { + let body = rar_volume(RAR4_MAGIC); + let dir = tempfile::tempdir().unwrap(); + for n in 45..=47u32 { + fs::write(dir.path().join(format!("{HASH}.{n}")), &body).unwrap(); + } + fs::write(dir.path().join(format!("{HASH}.par2")), b"").unwrap(); + dir +} + +#[test] +fn pos_obfuscated_numeric_volumes_are_detected_as_rar() { + let dir = obfuscated_dir(); + let archives = rar_archives(dir.path()); + assert!( + !archives.is_empty(), + "obfuscated RAR set went undetected; find_archives returned nothing, \ + so the Extract stage reports \"No archives found\" and skips" + ); + assert_eq!( + archives.len(), + 1, + "exactly one volume should start extraction, got {archives:?}" + ); +} + +#[test] +fn pos_obfuscated_volumes_are_not_usable_output() { + let dir = obfuscated_dir(); + assert!( + !has_usable_output(dir.path()).unwrap(), + "raw .NN volumes were reported as usable payload, which is what \ + lets move_to_history stamp the job Completed" + ); +} + +#[test] +fn pos_obfuscated_volumes_are_cleanup_candidates() { + let dir = obfuscated_dir(); + let cleanup = cleanup_names(dir.path()); + for n in 45..=47u32 { + let name = format!("{HASH}.{n}"); + assert!( + cleanup.contains(&name), + "{name} should be a cleanup candidate; got {cleanup:?}" + ); + } +} + +#[test] +fn pos_obfuscated_volume_numbers_are_parsed() { + // Name alone cannot distinguish an obfuscated volume from any other file + // ending in digits, so numbering is resolved through the content-aware + // entry point. `parse_rar_volume` deliberately still rejects these. + let dir = obfuscated_dir(); + assert!( + parse_rar_volume(&format!("{HASH}.45")).is_none(), + "the name-only parser must not claim bare numeric extensions" + ); + + let parsed = parse_rar_volume_at(&dir.path().join(format!("{HASH}.45"))) + .expect("bare numeric RAR volume should parse from disk"); + assert_eq!(parsed.set_name, HASH); + + let next = parse_rar_volume_at(&dir.path().join(format!("{HASH}.46"))) + .expect("bare numeric RAR volume should parse from disk"); + assert_eq!( + next.volume_number, + parsed.volume_number + 1, + "consecutive numeric volumes must order consecutively" + ); +} + +#[test] +fn guard_numeric_file_without_signature_is_not_a_volume() { + // The content check is what keeps `parse_rar_volume_at` honest. + let dir = make_dir(&[("logfile.01", b"2026-08-11 INFO started")]); + assert!( + parse_rar_volume_at(&dir.path().join("logfile.01")).is_none(), + "a numeric-suffixed file with no RAR signature is not a volume" + ); +} + +#[test] +fn pos_obfuscated_rar5_volumes_are_detected() { + let body = rar_volume(RAR5_MAGIC); + let dir = tempfile::tempdir().unwrap(); + for n in 1..=3u32 { + fs::write(dir.path().join(format!("{HASH}.{n:02}")), &body).unwrap(); + } + assert!( + !rar_archives(dir.path()).is_empty(), + "RAR5 obfuscated set went undetected" + ); +} + +// --------------------------------------------------------------------------- +// False-positive guards — a naive "any numeric extension is a RAR volume" fix +// breaks these. They are why detection sniffs the RAR signature. +// --------------------------------------------------------------------------- + +#[test] +fn guard_numeric_suffix_without_rar_signature_is_not_an_archive() { + // Looks like `.NN` but holds no RAR signature — e.g. a stray data + // file. Detection must not claim it. + let dir = make_dir(&[ + ( + "Concert.Recording.1987", + b"plain text notes, not an archive", + ), + ("logfile.01", b"2026-08-11 INFO started"), + ]); + assert!( + rar_archives(dir.path()).is_empty(), + "non-RAR numeric-suffixed files must not be detected as archives" + ); +} + +#[test] +fn guard_split_7z_volumes_are_not_reclassified_as_rar() { + // `.001` is numeric too. It must stay a 7z volume, not become a RAR set, + // or extraction hands the wrong file to the wrong extractor. + let dir = make_dir(&[ + ("archive.7z.001", b"7z\xbc\xaf\x27\x1c"), + ("archive.7z.002", b"more data"), + ]); + assert!( + rar_archives(dir.path()).is_empty(), + "split 7z volumes must not be classified as RAR" + ); + assert_eq!(find_archives(dir.path()).len(), 1); +} + +#[test] +fn guard_media_file_with_year_suffix_stays_payload() { + // `has_usable_output` must not start discarding real payload just because + // the name ends in digits. + let dir = make_dir(&[("Movie.Title.2024.mkv", b"\x1a\x45\xdf\xa3matroska")]); + assert!(has_usable_output(dir.path()).unwrap()); +} diff --git a/tests/par2_deobfuscation_gate.rs b/tests/par2_deobfuscation_gate.rs new file mode 100644 index 0000000..d45bfb1 --- /dev/null +++ b/tests/par2_deobfuscation_gate.rs @@ -0,0 +1,211 @@ +//! Regression suite for the second half of issue #87: the PAR2-guided +//! deobfuscation that fixes obfuscated names used to be skipped on a healthy +//! download. +//! +//! `rename_to_par2_names` (pipeline.rs) recovers original filenames by matching +//! each file's first-16K MD5 against the PAR2 metadata — exactly what an +//! obfuscated `.NN` set needs. It used to be called only from inside the +//! verify branch, and verification is skipped outright when +//! `articles_failed == 0` ("files are known-good from CRC checks"), so the one +//! mechanism that can deobfuscate the set only ran when the download was +//! *damaged*. +//! +//! The fix runs the rename whenever the PAR2 index parses, independent of the +//! verify decision. These tests pin that: the pairing mirrors +//! `obfuscated_rar_volumes.rs`: +//! +//! * `neg_*` — the damaged path, where the rename always ran. Passed before +//! the fix too; proves the rename machinery itself was never the problem. +//! * `pos_*` — the healthy path. Same files, same PAR2, only +//! `articles_failed` differs. This is the test that failed before the fix. +//! * `guard_*` — the rename must stay targeted now that it runs on every job. +//! +//! Extraction and cleanup are disabled throughout so the assertions are about +//! filenames on disk and nothing else — these tests need no `unrar` binary. + +mod support; + +use std::fs; +use std::path::Path; + +use nzb_postproc::{PostProcConfig, run_pipeline}; +use support::par2_fixture::Par2Fixture; + +/// The obfuscated set name from the issue report. +const HASH: &str = "cfd4be79c0fb01d429c52a9f8551ee79"; + +/// Canonical names PAR2 knows the volumes by. +const CANONICAL: [&str; 3] = [ + "Movie.Title.2024.part001.rar", + "Movie.Title.2024.part002.rar", + "Movie.Title.2024.part003.rar", +]; + +/// Volume bodies: real RAR signature plus per-volume filler so each file has a +/// distinct 16K hash and matching is unambiguous. +fn volume_bodies() -> Vec> { + (0..3u8) + .map(|n| { + let mut body = b"Rar!\x1a\x07\x00".to_vec(); + body.extend_from_slice(&[n; 512]); + body + }) + .collect() +} + +/// A job directory holding the three volumes under `names`, plus a PAR2 index +/// that records them under their canonical names. +fn job_dir(names: &[String]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let bodies = volume_bodies(); + + let mut fixture = Par2Fixture::new(); + for (canonical, body) in CANONICAL.iter().zip(&bodies) { + fixture = fixture.add_file(canonical, body); + } + fixture.write_index(&dir.path().join("Movie.Title.2024.par2")); + + for (name, body) in names.iter().zip(&bodies) { + fs::write(dir.path().join(name), body).unwrap(); + } + dir +} + +/// The obfuscated layout: `.45`, `.46`, `.47`. +fn obfuscated_names() -> Vec { + (45..=47u32).map(|n| format!("{HASH}.{n}")).collect() +} + +/// Pipeline config that isolates the verify/rename stage. +fn config(articles_failed: usize) -> PostProcConfig { + PostProcConfig { + articles_failed, + skip_extract: true, + cleanup_after_extract: false, + ..Default::default() + } +} + +/// Sorted non-par2 filenames in `dir`. +fn names_on_disk(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.to_lowercase().ends_with(".par2")) + .collect(); + names.sort(); + names +} + +// --------------------------------------------------------------------------- +// Fixture self-test — if this breaks, every result below is meaningless. +// --------------------------------------------------------------------------- + +#[test] +fn neg_par2_fixture_is_parseable_and_names_the_files() { + let dir = job_dir(&obfuscated_names()); + let index = dir.path().join("Movie.Title.2024.par2"); + + let file_set = rust_par2::parse(&index).expect("synthesised PAR2 index must parse"); + let mut names: Vec = file_set + .files + .values() + .map(|f| f.filename.clone()) + .collect(); + names.sort(); + assert_eq!( + names, + CANONICAL.iter().map(|s| s.to_string()).collect::>(), + "synthesised PAR2 index must report the canonical filenames" + ); + + // The 16K hashes must match what the rename path computes, or matching + // would silently never fire and the gate tests would prove nothing. + for (canonical, body) in CANONICAL.iter().zip(volume_bodies()) { + let probe = dir.path().join("probe.bin"); + fs::write(&probe, &body).unwrap(); + let hash = rust_par2::compute_hash_16k(&probe).unwrap(); + assert!( + file_set + .files + .values() + .any(|f| f.filename == *canonical && f.hash_16k == hash), + "16K hash recorded for {canonical} must match compute_hash_16k" + ); + fs::remove_file(&probe).unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Negative control — damaged download. The rename always ran here. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn neg_damaged_download_deobfuscates_via_par2() { + let dir = job_dir(&obfuscated_names()); + + // articles_failed > 0 → verify branch runs → rename_to_par2_names runs. + let _ = run_pipeline(dir.path(), &config(1)).await; + + assert_eq!( + names_on_disk(dir.path()), + CANONICAL.iter().map(|s| s.to_string()).collect::>(), + "with articles_failed > 0 the PAR2-guided rename should restore the \ + canonical names" + ); +} + +// --------------------------------------------------------------------------- +// Healthy download — this is the case that failed before the fix. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn pos_healthy_download_deobfuscates_via_par2() { + let dir = job_dir(&obfuscated_names()); + + // Identical inputs to the negative control; only articles_failed differs. + let _ = run_pipeline(dir.path(), &config(0)).await; + + assert_eq!( + names_on_disk(dir.path()), + CANONICAL.iter().map(|s| s.to_string()).collect::>(), + "a clean download of an obfuscated post kept its .NN names — the \ + PAR2-guided rename has been re-gated behind the verify branch, which \ + articles_failed == 0 skips, so the set is never deobfuscated and the \ + Extract stage finds no archives (issue #87)" + ); +} + +// --------------------------------------------------------------------------- +// Guards — the rename must stay targeted now that it runs on every job. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn guard_correctly_named_files_are_left_alone() { + let canonical: Vec = CANONICAL.iter().map(|s| s.to_string()).collect(); + let dir = job_dir(&canonical); + + let _ = run_pipeline(dir.path(), &config(1)).await; + + assert_eq!( + names_on_disk(dir.path()), + canonical, + "files already matching PAR2 must not be renamed" + ); +} + +#[tokio::test] +async fn guard_unrelated_files_are_not_renamed() { + // A file PAR2 knows nothing about must keep its name — lifting the gate + // must not turn the rename into a free-for-all over the job directory. + let dir = job_dir(&obfuscated_names()); + fs::write(dir.path().join("readme.nfo"), b"release notes").unwrap(); + + let _ = run_pipeline(dir.path(), &config(1)).await; + + assert!( + dir.path().join("readme.nfo").exists(), + "a file absent from the PAR2 set must be left untouched" + ); +} diff --git a/tests/par2_slice_fixture.rs b/tests/par2_slice_fixture.rs new file mode 100644 index 0000000..cc9f4bc --- /dev/null +++ b/tests/par2_slice_fixture.rs @@ -0,0 +1,59 @@ +//! WI-151: the PAR2 fixture emits IFSC slice checksums that `rust_par2` +//! accepts. A correct file verifies intact through those per-slice checksums, +//! and a single corrupted slice is pinpointed by its block index — which only +//! works if the fixture's per-slice MD5/CRC32 are byte-exact. + +mod support; + +use std::fs; + +use support::par2_fixture::Par2Fixture; + +fn payload(len: usize) -> Vec { + (0..len).map(|i| (i % 251) as u8).collect() +} + +#[test] +fn intact_file_verifies_through_slice_checksums() { + let dir = tempfile::tempdir().unwrap(); + // 10_000 bytes at the fixture's 4096-byte slice size -> 3 slices, the last + // one partial (zero-padded for hashing). + let data = payload(10_000); + let name = "movie.mkv"; + Par2Fixture::new() + .add_file(name, &data) + .write_index(&dir.path().join("recovery.par2")); + fs::write(dir.path().join(name), &data).unwrap(); + + let set = rust_par2::parse(&dir.path().join("recovery.par2")).unwrap(); + let file = set.files.values().next().unwrap(); + assert_eq!(file.slices.len(), 3, "three slices including one partial"); + + let result = rust_par2::verify(&set, dir.path()); + assert!(result.all_correct(), "intact fixture must verify: {result}"); +} + +#[test] +fn corrupt_slice_is_located_by_its_block_index() { + let dir = tempfile::tempdir().unwrap(); + let data = payload(10_000); + let name = "movie.mkv"; + Par2Fixture::new() + .add_file(name, &data) + .write_index(&dir.path().join("recovery.par2")); + + // Flip a byte inside slice index 1 (bytes 4096..8192). + let mut corrupt = data.clone(); + corrupt[5000] ^= 0xff; + fs::write(dir.path().join(name), &corrupt).unwrap(); + + let set = rust_par2::parse(&dir.path().join("recovery.par2")).unwrap(); + let result = rust_par2::verify(&set, dir.path()); + + assert_eq!(result.damaged.len(), 1, "one damaged file: {result}"); + assert_eq!( + result.damaged[0].damaged_block_indices, + vec![1], + "IFSC checksums must pinpoint the corrupted slice" + ); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..c226ad9 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,3 @@ +//! Shared test support for `nzb-postproc` integration tests. + +pub mod par2_fixture; diff --git a/tests/support/par2_fixture.rs b/tests/support/par2_fixture.rs new file mode 100644 index 0000000..c5db01d --- /dev/null +++ b/tests/support/par2_fixture.rs @@ -0,0 +1,193 @@ +//! Synthesise real, parseable PAR2 index files for integration tests. +//! +//! The repo has no `.par2` fixtures and no `par2create` binary in CI, which +//! previously made the PAR2-guided deobfuscation path (`rename_to_par2_names`) +//! untestable end-to-end. This builder writes spec-correct PAR2 packets so +//! `rust_par2::parse` accepts them and the pipeline exercises the real code +//! path rather than a mock. +//! +//! Only the packets the pipeline actually reads are emitted: +//! +//! * **Main** — carries the slice size; `parse` returns `NoMainPacket` without it. +//! * **FileDesc** — one per file: file ID, full-file MD5, first-16K MD5, size, +//! and the *expected* filename. The 16K hash is what `rename_to_par2_names` +//! matches obfuscated files against. +//! +//! * **IFSC** — one per file: the per-slice MD5 and CRC32 checksums, with the +//! final partial slice zero-padded to the slice size, exactly as PAR2 (and +//! `rust_par2`'s verifier) compute them. This is what lets `verify` report a +//! correct file as intact and what the in-flight slice verifier (WI-144) +//! checks decoded slices against. +//! +//! Recovery (`RecvSlic`) packets are still omitted: they only matter for actual +//! repair, which these fixtures do not exercise. `verify` reports the recovery +//! set as unrepairable (zero recovery blocks), which is fine — the assertions +//! here are about slice checksums and filenames, not repair. +//! +//! Packet layout implemented here (little-endian), per the PAR 2.0 spec: +//! +//! ```text +//! 0..8 magic "PAR2\0PKT" +//! 8..16 packet length (u64, whole packet, multiple of 4) +//! 16..32 MD5 of everything from offset 32 onward +//! 32..48 recovery set ID +//! 48..64 packet type +//! 64.. body +//! ``` + +use std::path::Path; + +use md5::{Digest, Md5}; + +const MAGIC: &[u8; 8] = b"PAR2\x00PKT"; +const TYPE_MAIN: &[u8; 16] = b"PAR 2.0\x00Main\x00\x00\x00\x00"; +const TYPE_FILE_DESC: &[u8; 16] = b"PAR 2.0\x00FileDesc"; +const TYPE_IFSC: &[u8; 16] = b"PAR 2.0\x00IFSC\x00\x00\x00\x00"; + +/// One file recorded in the recovery set. +struct FileEntry { + /// The name PAR2 considers canonical — what a rename should restore. + expected_name: String, + /// File ID (spec: MD5 of hash_16k + size + name). + file_id: [u8; 16], + hash: [u8; 16], + hash_16k: [u8; 16], + size: u64, + /// Per-slice (MD5, CRC32) checksums, in slice order. + slices: Vec<([u8; 16], u32)>, +} + +/// Builds a PAR2 index file describing a set of files by content. +pub struct Par2Fixture { + slice_size: u64, + recovery_set_id: [u8; 16], + files: Vec, +} + +impl Par2Fixture { + /// Start a new recovery set. `slice_size` must be a multiple of 4. + pub fn new() -> Self { + Self { + slice_size: 4096, + // Fixed, not random: tests must be deterministic. + recovery_set_id: *b"rustnzbfixture01", + files: Vec::new(), + } + } + + /// Record `contents` under the canonical name PAR2 should report. + /// + /// This does not write the file to disk — the test decides whether to + /// place it under its canonical name or an obfuscated one. + pub fn add_file(mut self, expected_name: &str, contents: &[u8]) -> Self { + let hash: [u8; 16] = Md5::digest(contents).into(); + // Mirrors `rust_par2::compute_hash_16k`: MD5 of the first 16 KiB. + let head = &contents[..contents.len().min(16384)]; + let hash_16k: [u8; 16] = Md5::digest(head).into(); + let size = contents.len() as u64; + + // Spec: File ID = MD5(hash_16k || size_le || name). + let mut id_input = Vec::new(); + id_input.extend_from_slice(&hash_16k); + id_input.extend_from_slice(&size.to_le_bytes()); + id_input.extend_from_slice(expected_name.as_bytes()); + let file_id: [u8; 16] = Md5::digest(&id_input).into(); + + // Per-slice checksums for the IFSC packet. Each slice is hashed + // zero-padded to the slice size, matching PAR2 and rust_par2's verifier. + let slice_size = self.slice_size as usize; + let mut slices = Vec::new(); + let mut off = 0; + while off < contents.len() { + let end = (off + slice_size).min(contents.len()); + let mut chunk = contents[off..end].to_vec(); + chunk.resize(slice_size, 0); + let slice_md5: [u8; 16] = Md5::digest(&chunk).into(); + let mut crc = crc32fast::Hasher::new(); + crc.update(&chunk); + slices.push((slice_md5, crc.finalize())); + off = end; + } + + self.files.push(FileEntry { + expected_name: expected_name.to_string(), + file_id, + hash, + hash_16k, + size, + slices, + }); + self + } + + /// Write the index `.par2` file to `path`. + pub fn write_index(&self, path: &Path) { + let mut out = Vec::new(); + + // Main packet: slice size, file count, then the file IDs in order. + let mut main_body = Vec::new(); + main_body.extend_from_slice(&self.slice_size.to_le_bytes()); + main_body.extend_from_slice(&(self.files.len() as u32).to_le_bytes()); + for file in &self.files { + main_body.extend_from_slice(&file.file_id); + } + out.extend_from_slice(&self.packet(TYPE_MAIN, &main_body)); + + // One FileDesc packet per file. + for file in &self.files { + let mut body = Vec::new(); + body.extend_from_slice(&file.file_id); + body.extend_from_slice(&file.hash); + body.extend_from_slice(&file.hash_16k); + body.extend_from_slice(&file.size.to_le_bytes()); + body.extend_from_slice(file.expected_name.as_bytes()); + // Filename is null-padded to a multiple of 4 so the packet length + // stays 4-aligned; the parser rejects lengths that are not. + while body.len() % 4 != 0 { + body.push(0); + } + out.extend_from_slice(&self.packet(TYPE_FILE_DESC, &body)); + } + + // One IFSC packet per file: file ID then (MD5[16] + CRC32[4]) per slice. + // 16 + 20*n is always 4-aligned, so the packet length stays valid. + for file in &self.files { + let mut body = Vec::new(); + body.extend_from_slice(&file.file_id); + for (slice_md5, crc32) in &file.slices { + body.extend_from_slice(slice_md5); + body.extend_from_slice(&crc32.to_le_bytes()); + } + out.extend_from_slice(&self.packet(TYPE_IFSC, &body)); + } + + std::fs::write(path, &out).unwrap(); + } + + /// Frame `body` as a PAR2 packet with a correct length and MD5. + /// + /// The parser recomputes the MD5 over everything from offset 32 and skips + /// packets that don't match, so this must be exact — a wrong hash makes + /// the fixture silently empty rather than failing loudly. + fn packet(&self, packet_type: &[u8; 16], body: &[u8]) -> Vec { + // `data` is what the MD5 covers: set ID + type + body. + let mut data = Vec::with_capacity(32 + body.len()); + data.extend_from_slice(&self.recovery_set_id); + data.extend_from_slice(packet_type); + data.extend_from_slice(body); + + let packet_len = (32 + data.len()) as u64; + assert!( + packet_len % 4 == 0, + "PAR2 packet length must be 4-aligned, got {packet_len}" + ); + let md5: [u8; 16] = Md5::digest(&data).into(); + + let mut packet = Vec::with_capacity(packet_len as usize); + packet.extend_from_slice(MAGIC); + packet.extend_from_slice(&packet_len.to_le_bytes()); + packet.extend_from_slice(&md5); + packet.extend_from_slice(&data); + packet + } +}