From 1267a3f1137dbe04c383761e737c99aa14db8b72 Mon Sep 17 00:00:00 2001 From: ys Date: Mon, 5 Oct 2026 13:43:58 +0100 Subject: [PATCH] Fixed PS3 region parsing and capacity-overflow panics from invalid region counts. Fixed oversized default chunk allocations in drag-and-drop mode. Read, write, and disk-sync failures now stop decryption instead of reporting success. Existing unrelated output files are no longer overwritten. Failed partial outputs are kept for automatic recovery. Thread count now defaults to your detected logical CPU count; `--tc` still overrides it. Simpler CLI flow and chunk defaults. Executable renamed from `ps3decrs` to `ps3dec` (`ps3dec.exe` on Windows). Sector decryption now uses RustCrypto's CBC crate instead of manual CBC chaining. Rust toolchain pinned to 1.99.0. dded GitHub Actions builds for Linux, Windows, and macOS on x86-64 and ARM64. Automatic dev previews and version-tagged stable releases, with generated notes and download checksums. Added a short performance-tuning table to the README. Drag and drop multiple ISOs into a full-screen queue with live logs, speed, and ETA. `--jobs` runs concurrent files; `--skip` exits when done. Opening without arguments watches the current directory every 300 ms. New ISOs wait for `s`; `c` cancels the selected file without stopping the others. Interrupted files resume automatically on the next run from their last synced chunk. Completed batch files are skipped when their checkpoint matches. Added end-to-end tests using one fixed test ISO with PS3 headers and OpenSSL-encrypted payloads; Linux CI checks decryption, batches, and crash recovery through the real executable. --- .github/workflows/build-release.yml | 182 ++++++++ .gitignore | 1 + Cargo.lock | 658 +++++++++++++++++++++++++++- Cargo.toml | 17 +- README.md | 119 +++-- rust-toolchain.toml | 8 + src/args.rs | 38 +- src/autodetect.rs | 16 +- src/lib.rs | 388 ++++++++++------ src/logging.rs | 91 ++++ src/main.rs | 163 ++----- src/queue.rs | 638 +++++++++++++++++++++++++++ src/recover.rs | 362 +++++++++++++++ src/ui.rs | 267 +++++++++++ src/utils.rs | 135 +++--- tests/e2e.py | 167 +++++++ tests/fake_iso.py | 64 +++ tests/keys/Fake.dkey | 1 + 18 files changed, 2935 insertions(+), 380 deletions(-) create mode 100644 .github/workflows/build-release.yml create mode 100644 rust-toolchain.toml create mode 100644 src/logging.rs create mode 100644 src/queue.rs create mode 100644 src/recover.rs create mode 100644 src/ui.rs create mode 100644 tests/e2e.py create mode 100644 tests/fake_iso.py create mode 100644 tests/keys/Fake.dkey diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..1ab7d44 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,182 @@ +name: Build and Release PS3Dec + +on: + push: + branches: [main, dev] + tags: ['v*'] + pull_request: + branches: [main, dev] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + binary: ps3dec + archive: tar.gz + rustflags: '' + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + binary: ps3dec + archive: tar.gz + rustflags: '' + - os: windows-2022 + target: x86_64-pc-windows-msvc + binary: ps3dec.exe + archive: zip + rustflags: '-C target-feature=+crt-static' + - os: windows-11-arm + target: aarch64-pc-windows-msvc + binary: ps3dec.exe + archive: zip + rustflags: '-C target-feature=+crt-static' + - os: macos-15-intel + target: x86_64-apple-darwin + binary: ps3dec + archive: tar.gz + rustflags: '' + - os: macos-15 + target: aarch64-apple-darwin + binary: ps3dec + archive: tar.gz + rustflags: '' + defaults: + run: + shell: bash + env: + CARGO_TERM_COLOR: always + TARGET: ${{ matrix.target }} + BINARY: ${{ matrix.binary }} + ASSET: ps3dec-${{ matrix.target }}.${{ matrix.archive }} + RUSTFLAGS: ${{ matrix.rustflags }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Install the repository's pinned Rust toolchain + run: | + rustup show active-toolchain + rustup target add "$TARGET" + rustc --version + cargo --version + + - name: Cache Rust dependencies + uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + with: + key: ${{ matrix.target }} + save-if: ${{ github.event_name != 'pull_request' }} + + - name: Build release binary + run: cargo build --release --locked --bin ps3dec --target "$TARGET" + + - name: Smoke-check the native executable + run: '"target/$TARGET/release/$BINARY" --help' + + - name: Install ISO fixture tools + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y genisoimage openssl + + - name: End-to-end decryption checks + if: runner.os == 'Linux' + run: | + cargo build --locked --bin ps3dec + python3 -B tests/e2e.py + + - name: Prepare package + run: | + mkdir dist + cp "target/$TARGET/release/$BINARY" README.md LICENSE dist/ + { + printf 'Commit: %s\nTarget: %s\n' "$GITHUB_SHA" "$TARGET" + rustc --version + } > dist/BUILD.txt + + - name: Package Windows ZIP + if: runner.os == 'Windows' + shell: pwsh + run: Compress-Archive -Path dist/* -DestinationPath $env:ASSET + + - name: Package Linux / macOS tarball + if: runner.os != 'Windows' + run: tar -czf "$ASSET" -C dist . + + - name: Upload build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ps3dec-${{ matrix.target }} + path: ${{ env.ASSET }} + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + release: + name: Publish release + needs: build + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + defaults: + run: + shell: bash + steps: + - name: Checkout full history for branch validation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify stable release comes from main + if: startsWith(github.ref, 'refs/tags/') + run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main + + - name: Download all platform archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ps3dec-* + path: artifacts + merge-multiple: true + + - name: Generate checksums + working-directory: artifacts + run: sha256sum *.tar.gz *.zip > SHA256SUMS + + - name: Publish release with generated notes + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1 + with: + tag: ${{ github.ref == 'refs/heads/dev' && format('dev-{0}', github.run_number) || github.ref_name }} + commit: ${{ github.sha }} + prerelease: ${{ github.ref == 'refs/heads/dev' }} + makeLatest: ${{ github.ref != 'refs/heads/dev' }} + generateReleaseNotes: true + body: | + Built from commit [${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}). + [Build run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). + Verify downloads with SHA256SUMS. + artifacts: artifacts/*.tar.gz,artifacts/*.zip,artifacts/SHA256SUMS + artifactErrorsFailBuild: true + immutableCreate: true + skipIfReleaseExists: true + allowUpdates: true diff --git a/.gitignore b/.gitignore index ea8c4bf..3c9015d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/tests/*.iso diff --git a/Cargo.lock b/Cargo.lock index 8bb8dc5..16810e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,12 @@ dependencies = [ "cpufeatures", ] +[[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" @@ -78,12 +84,32 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arc-swap" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "atomicwrites" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef1bb8d1b645fe38d51dfc331d720fb5fc2c94b440c76cc79c80ff265ca33e3" +dependencies = [ + "rustix 0.38.44", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -92,9 +118,27 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] [[package]] name = "bumpalo" @@ -102,6 +146,30 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.39" @@ -187,6 +255,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.16.1" @@ -200,6 +282,15 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -240,16 +331,84 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.5", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array 0.14.7", + "generic-array", "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "2.0.1" @@ -265,6 +424,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ + "convert_case", "proc-macro2", "quote", "syn", @@ -277,6 +437,25 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.15.0" @@ -295,6 +474,22 @@ 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.1", +] + +[[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.2" @@ -308,22 +503,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "generic-array" -version = "0.14.7" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" dependencies = [ - "typenum", - "version_check", + "libc", + "winapi", ] [[package]] name = "generic-array" -version = "1.2.0" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c8444bc9d71b935156cc0ccab7f622180808af7867b1daae6547d773591703" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", + "version_check", ] [[package]] @@ -335,7 +537,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -343,6 +545,22 @@ name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -392,6 +610,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.11.4" @@ -399,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", ] [[package]] @@ -415,13 +639,36 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array 0.14.7", + "block-padding", + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -430,6 +677,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -446,11 +702,55 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.0", + "portable-atomic", + "thiserror", +] + [[package]] name = "libc" -version = "0.2.176" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -506,18 +806,45 @@ dependencies = [ "winapi", ] +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.1", +] + [[package]] name = "mock_instant" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" +[[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" @@ -537,6 +864,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -558,6 +894,39 @@ dependencies = [ "num-traits", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -583,9 +952,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" @@ -610,15 +985,23 @@ name = "ps3decrs" version = "2.0.1" dependencies = [ "aes", + "atomicwrites", + "cbc", "chrono", "clap", - "generic-array 1.2.0", + "console", + "fs2", "hex", "indicatif", "log", "log4rs", "num_cpus", + "ratatui", "rayon", + "same-file", + "serde", + "serde_json", + "sha2", ] [[package]] @@ -665,6 +1048,72 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.11.0" @@ -694,6 +1143,32 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.1", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -706,6 +1181,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[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" @@ -778,24 +1262,93 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.106" @@ -807,6 +1360,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.1", +] + [[package]] name = "thiserror" version = "2.0.16" @@ -837,6 +1403,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "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 = "typemap-ors" version = "1.0.0" @@ -864,6 +1451,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.1" @@ -909,6 +1507,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasi" version = "0.14.7+wasi-0.2.4" @@ -1012,6 +1616,15 @@ 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.1", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1077,6 +1690,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index b8e2d16..3a97609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,29 +8,35 @@ readme = "README.md" license = "MIT" keywords = ["ps3", "iso", "decryption", "bluray"] categories = ["command-line-utilities", "cryptography"] -rust-version = "1.85" +rust-version = "1.88" include = ["src/**", "Cargo.toml", "README*", "LICENSE*"] [dependencies] clap = { version = "4.5.2", features = ["derive"] } aes = "0.8.4" +cbc = "0.1.2" hex = "0.4.3" indicatif = "0.18.0" +console = "0.16.1" +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "std"] } rayon = "1.9.0" -generic-array = "1.2.0" log = "0.4" log4rs = "1.0" chrono = "0.4" num_cpus = "1.17.0" +atomicwrites = "0.4.4" +fs2 = "0.4.3" +same-file = "1.0.6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10.9" [profile.release] opt-level = 3 strip = true lto = "fat" -[build] -rustflags = ["-C", "target-cpu=native"] [lib] name = "ps3dec" @@ -38,6 +44,5 @@ path = "src/lib.rs" crate-type = ["cdylib", "rlib"] [[bin]] -name = "ps3decrs" +name = "ps3dec" path = "src/main.rs" - diff --git a/README.md b/README.md index d334dfc..f60f7d9 100644 --- a/README.md +++ b/README.md @@ -40,40 +40,112 @@ https://github.com/user-attachments/assets/978c1827-d788-449a-a52f-6743e94cb4db ## Usage -| Option | Description | Note | -|-----------------|-------------------------------------------------------------------|-----------------------------------------------------------| -| `--iso` | For the ISO file | | -| `--dk` | For decryption key, a base-16 hex key | | -| `--tc` | Thread count, specifies the number of threads | Be careful with this one | -| `--auto` | Enables automatic key detection and decryption | Will only work if there is the key in the **keys** folder | -| `--skip` | Disables the press any key to exit after decryption | | -| `--output_dir` | output directory destination for the decrypted iso | | -| `--output_name` | output file name for the decrypted iso | | -| `--chunk-size` | how big the chunk of the iso is processed at the same time in MIB | Read down below about chunk size section. | +### command line flags + +| Option | What it does | Note | +|--------|--------------|------| +| `...` | ISO files to decrypt; drag and drop works too | One ISO uses the normal CLI. Multiple ISOs open the TUI in a terminal. Leave this out to watch the current directory. | +| `-k`, `--dk ` | Use your own decryption key | 32 hex characters. Single ISO only; don't combine with `--auto`. | +| `-t`, `--tc ` | How many CPU threads to use | Defaults to your machine's CPU thread count. More isn't always faster. | +| `-a`, `--auto` | Find the key using the ISO name | Needs a matching key in **keys/**. Already enabled for batches and directory watching. | +| `-s`, `--skip` | Exit when done instead of waiting for Enter | Paused TUI jobs and directory watching keep the app open. | +| `-o`, `--output-dir ` | Where to save decrypted ISOs | By default, outputs go beside their input ISOs. | +| `-n`, `--output-name ` | Choose the output filename | Single ISO only. Leave out the `.iso` extension. | +| `--chunk-size ` | How much of an ISO to process at once | Default: 16 MiB. Bigger chunks use more RAM; see [performance and tuning](#performance-and-tuning). | +| `--jobs ` | How many ISOs to decrypt at once | Default: 1. Can't be combined with `--sequential`. | +| `--sequential` | Decrypt ISOs one after another | Can't be combined with `--jobs`. | +| `-h`, `--help` | Show the command line options | | +| `-V`, `--version` | Show the version | | ``` -ps3dec.exe --iso game.iso --dk yourdecryptionkey --tc 64 --chunk-size 16 +ps3dec.exe game.iso --dk yourdecryptionkey --tc 64 --chunk-size 16 ``` -If you don't want to keep changing your decryption key every time you can use --auto flag , which will look -inside a folder called **keys** containing the entire PS3 keys library which you can fetch from aldostools dkey database -here [Aldostools dkeys](https://ps3.aldostools.org/dkey.html) , to note that only .dkey files containing a base-16 hex -key are compatible. +If you don't want to keep typing your key, use [--auto](#command-line-flags). Put your `.dkey` files in **keys/**; you can get them from [Aldostools dkeys](https://ps3.aldostools.org/dkey.html). The key needs to be 32 hex characters. ``` -ps3dec.exe --iso game.iso --auto --tc 64 +ps3dec.exe game.iso --auto --tc 64 +``` + +### multiple ISOs and recovery + +Drop several ISOs onto the executable, or run: + +```sh +ps3dec game1.iso game2.iso --jobs 2 --skip +``` + +The TUI shows each ISO's progress, speed, time left, and logs. ISOs you pass on the command line start automatically. Use [--jobs](#command-line-flags) to run more than one at once, and [--skip](#command-line-flags) if you want it to close when done. Otherwise it stays open. + +You can also run it without any ISO paths: + +```sh +ps3dec ``` -### about chunk size parameter -I have added a new flag called `--chunk-size` It defines how many bytes you read, decrypt, and write per thread! -we batch sectors in a chunk and decrypt them then write to disk. -this was a request, and I am not sure how much benefit this can bring besides tweaking ram and disk usage. +This watches the directory you ran it from then use the [TUI shortcuts](#tui-shortcuts) to start decrypting them. + +If you run one ISO, or send the output to a file or pipe, it keeps the normal CLI view but if one ISO fails the others can still finish, but the app reports an error when you close it. + +#### TUI shortcuts + +| Key | What it does | Note | +|-----|--------------|------| +| ↑ / ↓ | Select an ISO | | +| `s` | Start or resume the selected ISO | Also restarts a stopped or failed ISO. Doesn't change its place in the queue. | +| `p` | Pause or resume the selected ISO | Finishes writing the current chunk first, then lets another queued ISO run. Waiting or queued ISOs pause right away. | +| `c` | Stop the selected ISO | Keeps unfinished output so you can resume with `s`. | +| `+` or `=` | Move the selected ISO up the queue | `=` works without Shift. | +| `-` | Move the selected ISO down the queue | | +| `q`, `Esc`, or `Ctrl+C` | Close the TUI | Running ISOs finish writing their current chunks before stopping. | +| `Enter` | Close when nothing is running or queued | | + + +#### recovery + +If you stop halfway do not delete the `.part` and `.resume` files. run the same command you ran again, or use the [TUI shortcuts](#tui-shortcuts), and it resumes what wasn't finished last session. + +Recovery needs the same ISO, key, and output path any changed files or broken recovery data are unusable, finished isos keep their `.resume` file so later runs can skip them, recovery files without their source ISO aren't picked up on their own. + +### performance and tuning + +On an SSD, you can try `--chunk-size 128 --tc 4`. These are starting points, not rules; more threads aren't always faster. Defaults and options are in the [flags table](#command-line-flags). + +| Setup | Chunk (MiB) | Threads (`--tc`) | Buffer RAM, roughly | +|-------|-------------|-----------------|---------------------| +| SSD baseline | 128 | 4 | 128 MiB | +| Strong CPU + fast NVMe | 128 | 8 | 128 MiB | +| Input/output on the same HDD | 128 | 1 | 128 MiB | +| Low RAM | 16 | 1–2 | 16 MiB | + +These numbers are for one ISO at a time! For example a 128 MiB chunk with two ISOs running needs about 256 MiB for the chunks, plus the app's other memory. The CPU threads are shared between the ISO. + +Bigger chunks use more RAM but mean fewer disk reads and writes. If you're reading and writing on the same HDD, stick to [--jobs 1](#command-line-flags) to avoid moving the drive head around as much. + +### tests + +The scripts in `tests/` generate one mock `Fake.iso` it has PS3 headers, and payload sectors encrypted by OpenSSL with a hardcoded test key they run the actual executable and compare the decrypted image. + +FYI this test image isn't bootable. + +Install `genisoimage` (or `mkisofs`), OpenSSL, and Python 3.11+, then run: + +```sh +cargo build --locked --bin ps3dec +python3 -B tests/e2e.py +``` + +To generate just the fixture: + +```sh +python3 -B tests/fake_iso.py +``` ## Building PS3dec -This works for macOS, Linux and windows +This works for macOS, Linux and windows and only 64bit builds. 1. Install Rust from https://rustup.rs/ 2. Clone the repository run ```git clone https://github.com/Redrrx/ps3dec @@ -84,7 +156,7 @@ cd ps3dec``` the output would be at /target If on linux run ```chmod+x target/release/ps3dec``` to make ps3dec an executable. -a very small note around here, if there's an issue that is specifically related to a library when it's targeting a platform I don't have much I can do about it, but you can fork the repository and find a replacement or a custom implementation, but this is unlikely as most of the libraries used are not reliant on any critical platform specific implementations and mostly standard ie: win api etc... +a very small note around here, if there's an issue that is specifically related to a library when it's targeting a platform I don't have much that I can do about it, but you can fork the repository and find a replacement or a custom implementation, but this is unlikely as most of the libraries used are not reliant on any critical platform specific implementations and mostly standard ie: win api etc... ### Building for a special platform? @@ -117,6 +189,3 @@ If you visit the releases page you might find two types - [Psdevwiki Bluray information ](https://www.psdevwiki.com/ps3/Bluray_disc) - [Understanding PS3 disk encryption](https://www.psx-place.com/threads/3k3y-iso-tools-understanding-ps3-disk-encryption.29903/) - The people who open issues/suggestions when they have any :D - - - diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7d1175a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,8 @@ +[toolchain] +channel = "1.99.0" +profile = "minimal" +components = [ + "rustfmt", + "clippy", + "rust-analyzer" +] diff --git a/src/args.rs b/src/args.rs index 7f6faab..d506bfa 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,18 +1,23 @@ - #[derive(Debug, clap::Parser)] #[clap( - author, version, + author, + version, about = "PS3dec Remake is a remake of the original PS3 DISC decryption tool in rust", long_about = "PS3Dec is a tool to decrypt PS3 Redump ISOs..." )] pub struct Ps3decargs { - #[clap(help = "Path to the PS3 ISO file to decrypt.")] - pub iso: String, - - #[clap(short = 'k', long = "dk", help = "Decryption key (32 hex).", conflicts_with = "auto")] + #[clap(num_args = 0.., help = "PS3 ISO files to decrypt; omit to open the directory queue.")] + pub iso: Vec, + + #[clap( + short = 'k', + long = "dk", + help = "Decryption key (32 hex).", + conflicts_with = "auto" + )] pub dk: Option, - #[clap(short = 't', long = "tc", help = "Thread count.", default_value = "32")] + #[clap(short = 't', long = "tc", help = "Thread count.", default_value_t = num_cpus::get())] pub tc: usize, #[clap(short, long, help = "Autodetect key from ISO name.", action = clap::ArgAction::SetTrue)] @@ -24,13 +29,26 @@ pub struct Ps3decargs { #[clap(short = 'o', long = "output-dir", help = "Output directory.")] pub output_dir: Option, - #[clap(short = 'n', long = "output-name", help = "Output filename (without extension).")] + #[clap( + short = 'n', + long = "output-name", + help = "Output filename (without extension)." + )] pub output_name: Option, #[clap(long = "chunk-size", help = "Chunk size in MiB.")] pub chunk_size: Option, -} + #[clap( + long, + default_value_t = 1, + conflicts_with = "sequential", + help = "Number of ISOs to process concurrently." + )] + pub jobs: usize, + #[clap(long, help = "Process ISOs one after another.")] + pub sequential: bool, +} -pub const DEFAULT_CHUNK: Option = Some(16 * 1024 * 1024); +pub const DEFAULT_CHUNK: usize = 16 * 1024 * 1024; diff --git a/src/autodetect.rs b/src/autodetect.rs index be42ba3..fcec3e5 100644 --- a/src/autodetect.rs +++ b/src/autodetect.rs @@ -11,12 +11,12 @@ pub fn detect_key(game_name: String) -> Result, String> { if filepath.is_file() && filepath - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|filename| filename.contains(&game_name)) + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|filename| filename.contains(&game_name)) { let msg = format!("Found key: {}", filepath.display()); - info!("{}", &msg); + info!("{msg}"); let key_data = fs::read(&filepath).map_err(|e| e.to_string())?; let key_string = String::from_utf8(key_data) @@ -33,15 +33,13 @@ pub fn detect_key(game_name: String) -> Result, String> { } let msg = "Key not found".to_string(); - println!("{}", &msg); - warn!("{}", &msg); + warn!("{msg}"); Ok(None) } Err(e) => { let msg = format!("Failed to read 'keys' directory: {}", e); - println!("{}", &msg); - warn!("{}", &msg); + warn!("{msg}"); Err(e.to_string()) } } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index c89cb9c..00ae5a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,23 +1,30 @@ +pub mod args; pub mod autodetect; +pub mod logging; +pub mod queue; +mod recover; +mod ui; pub mod utils; -pub mod args; + use aes::Aes128Dec; -use aes::cipher::{KeyInit, BlockDecryptMut}; -use aes::cipher::generic_array::{GenericArray, typenum::U16}; -use hex::decode; -use indicatif::{ProgressBar, ProgressStyle}; +use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; +use console::{Alignment, Term, measure_text_width, pad_str, style, truncate_str}; +use indicatif::{ + HumanBytes, HumanDuration, ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle, +}; use log::info; -use rayon::prelude::*; -use std::fs::{self, File, OpenOptions}; +use rayon::{ThreadPool, ThreadPoolBuilder, prelude::*}; +use std::fs::{self, File}; use std::io::{self, BufReader}; -use std::path::Path; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; -use utils::{ extract_regions, generate_iv, is_encrypted}; -const SECTOR_SIZE: usize = 2048; -use crate::args::DEFAULT_CHUNK; +use args::DEFAULT_CHUNK; +use recover::Recovery; +use utils::{extract_regions, generate_iv, is_encrypted}; +const SECTOR_SIZE: usize = 2048; pub fn decrypt( file_path: String, @@ -27,130 +34,265 @@ pub fn decrypt( output_name: Option, chunk_size: Option, ) -> io::Result<()> { - info!("Starting decryption process."); - let start_time = Instant::now(); - - rayon::ThreadPoolBuilder::new() - .num_threads(thread_count.max(1)) - .build_global() - .unwrap_or_else(|e| info!("Failed to set thread count, using default: {}", e)); - - let key_bytes = decode(decryption_key.trim()) - .map_err(|e| io::Error::other(e.to_string()))?; - if key_bytes.len() != 16 { - return Err(io::Error::other( - "decryption key must be 16 bytes (32 hex chars)", - )); - } - let key_ga: GenericArray = GenericArray::clone_from_slice(&key_bytes); - - let input_file = File::open(&file_path)?; - let total_size = input_file.metadata()?.len(); - if total_size % SECTOR_SIZE as u64 != 0 { - return Err(io::Error::other( - "input size is not a multiple of SECTOR_SIZE", - )); - } - let total_sectors = (total_size / SECTOR_SIZE as u64) as usize; - - info!( - "File size: {:.2} MB, Total sectors: {}", - total_size as f64 / 1_048_576.0, - total_sectors + let pool = cpu_pool(thread_count)?; + let input = Path::new(&file_path); + let output = output_path(input, output_dir.as_deref(), output_name.as_deref()); + let progress = progress_row(input, 1, &progress_style()); + progress.set_draw_target(ProgressDrawTarget::stderr()); + let result = decrypt_file( + input, + decryption_key, + &output, + chunk_size, + &pool, + &progress, + &AtomicBool::new(false), ); + if result.is_err() { + progress.abandon_with_message(progress_message("Failed")); + } + result +} - let mut region_reader = BufReader::with_capacity(256 * 1024, File::open(&file_path)?); - let regions = Arc::new(extract_regions(&mut region_reader)?); - info!("Total regions detected: {}", regions.len()); +pub(crate) fn cpu_pool(threads: usize) -> io::Result { + ThreadPoolBuilder::new() + .num_threads(threads.max(1)) + .build() + .map_err(io::Error::other) +} - let in_file = Arc::new(input_file); - let output_file_path = if let Some(dir) = output_dir { - let path = Path::new(&file_path); - let file_name = if let Some(name) = output_name { - format!("{name}.iso") - } else { - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("decrypted"); +pub(crate) fn output_path(input: &Path, directory: Option<&str>, name: Option<&str>) -> PathBuf { + let filename = match (directory, name) { + (_, Some(name)) => format!("{name}.iso"), + (Some(_), None) => { + let stem = input + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("decrypted"); format!("{stem}_decrypted.iso") - }; - let output_dir_path = Path::new(&dir); - if !output_dir_path.exists() { - fs::create_dir_all(output_dir_path)?; } - output_dir_path.join(file_name).to_string_lossy().to_string() - } else if let Some(name) = output_name { - format!("{name}.iso") - } else { - format!("{file_path}_decrypted.iso") + (None, None) => { + let mut path = input.as_os_str().to_os_string(); + path.push("_decrypted.iso"); + return PathBuf::from(path); + } }; - info!("Output will be written to: {}", output_file_path); - - let out_file = OpenOptions::create(OpenOptions::new().write(true), true).open(&output_file_path)?; - out_file.set_len(total_size)?; - let out_file = Arc::new(out_file); + directory.map_or_else( + || PathBuf::from(&filename), + |dir| Path::new(dir).join(&filename), + ) +} - let progress_bar = Arc::new(ProgressBar::new(total_sectors as u64)); - progress_bar.set_style( - ProgressStyle::default_bar() - .template("{spinner:.208} {elapsed_precise} |{bar:40.208/236}| {bytes:>8}/{total_bytes:8} ({bytes_per_sec}) ETA {eta_precise}") - .unwrap() - .progress_chars("█▓▒░"), - ); - let chunk_bytes = chunk_size - .map(|mib| mib.saturating_mul(1024 * 1024)) - .unwrap_or(DEFAULT_CHUNK.unwrap()); - let chunk_sectors = (chunk_bytes / SECTOR_SIZE).max(1); - let tasks: Vec<(usize, usize)> = (0..total_sectors) - .step_by(chunk_sectors) - .map(|s| (s, (s + chunk_sectors).min(total_sectors))) - .collect(); +fn progress_name_width() -> usize { + usize::from(Term::stderr().size().1) + .saturating_sub(65) + .clamp(12, 60) +} - tasks.into_par_iter().for_each(|(start_sector, end_sector)| { - let mut buf = vec![0u8; SECTOR_SIZE * (end_sector - start_sector)]; - if let Err(e) = utils::read_exact_at( - &in_file, - &mut buf, - (start_sector as u64) * (SECTOR_SIZE as u64), - ) { - eprintln!("Error reading data: {}", e); - return; +pub(crate) fn progress_style() -> ProgressStyle { + let name_width = progress_name_width(); + ProgressStyle::with_template(&format!( + "{{prefix:{name_width}}} │ {{msg}} │ {{bar:16.cyan/black}} {{percent:>3}}% │ {{bytes_per_sec:>12}} │ {{eta:>8}}", + )) + .expect("valid progress template") + .progress_chars("━━─") + .with_key("eta", |state: &ProgressState, out: &mut dyn std::fmt::Write| { + let incomplete = state.pos() < state.len().unwrap_or(0); + if state.per_sec() == 0.0 || (state.is_finished() && incomplete) { + write!(out, "--").unwrap(); + } else { + write!(out, "{:#}", HumanDuration(state.eta())).unwrap(); } + }) +} - let mut aes_core = Aes128Dec::new(&key_ga); - for (i, sector) in buf.chunks_mut(SECTOR_SIZE).enumerate() { - let sector_index = start_sector + i; - if !is_encrypted(®ions, sector_index as u64, sector) { - continue; - } - - let iv_ga = generate_iv(sector_index as u64); - let mut prev = [0u8; 16]; - prev.copy_from_slice(iv_ga.as_slice()); +pub(crate) fn progress_message(message: &str) -> String { + let text = pad_str(message, 12, Alignment::Left, Some("…")); + let text = match message { + "STATUS" => style(text).bold().dim(), + "Queued" => style(text).dim(), + "Failed" => style(text).red().bold(), + "Done" | "Already done" => style(text).green(), + "Resuming" | "Pausing" | "Paused" | "Stopped" => style(text).yellow(), + _ => style(text).cyan(), + }; + text.to_string() +} - for block in sector.chunks_exact_mut(16) { - let mut cur = [0u8; 16]; - cur.copy_from_slice(block); +pub(crate) fn progress_row(input: &Path, index: usize, style: &ProgressStyle) -> ProgressBar { + let progress = ProgressBar::with_draw_target(Some(1), ProgressDrawTarget::hidden()); + let name = input + .file_name() + .unwrap_or(input.as_os_str()) + .to_string_lossy(); + let name = format!("{index:>2} {name}").replace(char::is_control, " "); + let name_width = progress_name_width(); + let mut width = 0; + let tail = name + .char_indices() + .rev() + .take_while(|(_, c)| { + width += measure_text_width(&c.to_string()); + width <= name_width / 3 + }) + .last() + .map_or("", |(index, _)| &name[index..]); + progress.set_prefix(truncate_str(&name, name_width, &format!("…{tail}")).into_owned()); + progress.set_message(progress_message("Queued")); + progress.set_style(style.clone()); + progress +} - aes_core.decrypt_block_mut(GenericArray::from_mut_slice(block)); - for k in 0..16 { - block[k] ^= prev[k]; - } - prev = cur; - } - } +pub(crate) fn decrypt_file( + input_path: &Path, + decryption_key: &str, + output: &Path, + chunk_size: Option, + pool: &ThreadPool, + progress: &ProgressBar, + stop: &AtomicBool, +) -> io::Result<()> { + let start = Instant::now(); + let chunk_bytes = chunk_size + .map(|mib| mib.checked_mul(1024 * 1024).filter(|&size| size > 0)) + .unwrap_or(Some(DEFAULT_CHUNK)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "chunk size must be positive and fit in memory addressing", + ) + })?; + let key = hex::decode(decryption_key.trim()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + let key: [u8; 16] = key.try_into().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "decryption key must be 32 hex characters", + ) + })?; + let input = File::open(input_path)?; + let total_size = input.metadata()?.len(); + if total_size % SECTOR_SIZE as u64 != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "input size is not a multiple of 2048", + )); + } + let regions = extract_regions(&mut BufReader::new(input.try_clone()?))?; + let parent = output + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + fs::create_dir_all(parent)?; + let mut recovery = Recovery::open(input_path, &input, output, &key)?; + progress.set_length(total_size); + progress.set_position(recovery.offset()); + if recovery.is_complete() { + progress.finish_with_message(progress_message("Already done")); + info!("Already decrypted: {}", output.display()); + return Ok(()); + } + let remaining = total_size - recovery.offset(); + let available = fs2::available_space(parent).map_err(|e| { + io::Error::new( + e.kind(), + format!("cannot check free space in {}: {e}", parent.display()), + ) + })?; + if available < remaining { + return Err(io::Error::new( + io::ErrorKind::StorageFull, + format!( + "not enough free space in {}: need {} more, only {} available; free space and reopen PS3Dec to resume", + parent.display(), + HumanBytes(remaining), + HumanBytes(available) + ), + )); + } + progress.reset_elapsed(); + progress.set_message(progress_message(if recovery.offset() == 0 { + "Decrypting" + } else { + "Resuming" + })); + info!( + "Decrypting {} from byte {}", + input_path.display(), + recovery.offset() + ); - let off = (start_sector as u64) * (SECTOR_SIZE as u64); - if let Err(e) = utils::write_all_at(&out_file, &buf, off) { - eprintln!("Error writing data at offset {}: {}", off, e); + let capacity = remaining.min(chunk_bytes as u64) as usize; + let mut buffer = Vec::new(); + buffer.try_reserve_exact(capacity).map_err(|e| { + io::Error::new( + io::ErrorKind::OutOfMemory, + format!("cannot allocate {capacity} chunk bytes: {e}"), + ) + })?; + let key = (&key).into(); + while recovery.offset() < total_size { + if stop.load(Ordering::Relaxed) { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "decryption stopped; output is incomplete and kept for recovery", + )); } - - progress_bar.inc((end_sector - start_sector) as u64); - }); - - progress_bar.finish_with_message("Decryption completed"); - - let elapsed = start_time.elapsed(); - info!("Decryption completed in {:.2} seconds.", elapsed.as_secs_f64()); - info!("Data written to {}", output_file_path); - + let offset = recovery.offset(); + let length = (total_size - offset).min(chunk_bytes as u64) as usize; + buffer.resize(length, 0); + utils::read_exact_at(&input, &mut buffer, offset).map_err(|e| { + io::Error::new( + e.kind(), + format!("read {} at byte {offset}: {e}", input_path.display()), + ) + })?; + pool.install(|| { + buffer + .par_chunks_mut(SECTOR_SIZE) + .enumerate() + .try_for_each(|(i, sector)| { + let index = offset / SECTOR_SIZE as u64 + i as u64; + if !is_encrypted(®ions, index, sector) { + return Ok(()); + } + cbc::Decryptor::::new(key, &generate_iv(index)) + .decrypt_padded_mut::(sector) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid CBC block length in sector {index}"), + ) + })?; + Ok::<(), io::Error>(()) + }) + })?; + utils::write_all_at(recovery.file(), &buffer, offset).map_err(|e| { + io::Error::new( + e.kind(), + if e.kind() == io::ErrorKind::StorageFull { + format!( + "disk full while writing {}.part at byte {offset}; free space and reopen PS3Dec to resume", + output.display() + ) + } else { + format!( + "write {}.part at byte {offset}: {e}; output is incomplete and kept for recovery", + output.display() + ) + }, + ) + })?; + recovery.checkpoint(offset + length as u64, &buffer)?; + progress.set_position(recovery.offset()); + } + progress.set_message(progress_message("Finishing")); + recovery.finish()?; + progress.finish_with_message(progress_message("Done")); + info!( + "Decryption completed in {:.2}s: {}", + start.elapsed().as_secs_f64(), + output.display() + ); Ok(()) } diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..284bd23 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,91 @@ +use chrono::Local; +use indicatif::MultiProgress; +use log::{LevelFilter, Log, Metadata, Record}; +use log4rs::{ + append::{ + console::{ConsoleAppender, Target}, + file::FileAppender, + }, + config::{Appender, Config, Logger, Root}, + encode::pattern::PatternEncoder, +}; +use std::collections::VecDeque; +use std::fs; +use std::sync::{Arc, Mutex}; + +pub type LogBuffer = Arc>>; + +pub fn setup_logging( + progress: MultiProgress, + logs: Option, +) -> Result<(), Box> { + fs::create_dir_all("log")?; + let log_file_name = format!("log/{}.log", Local::now().format("%Y-%m-%d_%H-%M-%S")); + let fmt = "{d(%Y-%m-%d %H:%M:%S)} [{l}] - {m}\n"; + let logfile = FileAppender::builder() + .encoder(Box::new(PatternEncoder::new(fmt))) + .build(log_file_name)?; + + let mut config = + Config::builder().appender(Appender::builder().build("logfile", Box::new(logfile))); + let mut root = Root::builder().appender("logfile"); + if logs.is_some() { + config = config + .logger(Logger::builder().build("mio", LevelFilter::Warn)) + .logger(Logger::builder().build("crossterm", LevelFilter::Warn)); + } else { + let stdout = ConsoleAppender::builder() + .target(Target::Stderr) + .encoder(Box::new(PatternEncoder::new(fmt))) + .build(); + config = config.appender(Appender::builder().build("stdout", Box::new(stdout))); + root = root.appender("stdout"); + } + let logger = log4rs::Logger::new(config.build(root.build(LevelFilter::Trace))?); + let level = logger.max_log_level(); + log::set_boxed_logger(Box::new(ProgressLogger { + logger, + progress, + logs, + }))?; + log::set_max_level(level); + Ok(()) +} + +struct ProgressLogger { + logger: log4rs::Logger, + progress: MultiProgress, + logs: Option, +} + +impl Log for ProgressLogger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + self.logger.enabled(metadata) + } + + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + if let Some(logs) = &self.logs { + self.logger.log(record); + let line = format!( + "{} [{}] - {}", + Local::now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.args() + ); + let mut logs = logs.lock().unwrap_or_else(|e| e.into_inner()); + if logs.len() == 200 { + logs.pop_front(); + } + logs.push_back(line); + } else { + self.progress.suspend(|| self.logger.log(record)); + } + } + + fn flush(&self) { + self.logger.flush(); + } +} diff --git a/src/main.rs b/src/main.rs index 67a623d..a1e5edd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,126 +1,55 @@ - use clap::Parser; -use log::{ error, info}; -use std::io::{self}; -use std::path::Path; -use std::{env,}; -use std::process::exit; -pub mod args; -pub mod autodetect; -pub mod utils; - - -pub use utils::{ - read_exact_at, write_all_at, extract_regions, generate_iv, is_encrypted, - setup_logging, +use log::info; +use std::{ + env, + io::{self, IsTerminal}, + process::ExitCode, }; -use crate::args::{Ps3decargs, DEFAULT_CHUNK}; -use crate::autodetect::detect_key; -use crate::utils::key_validation; - -// Either drag and drop which will auto-detect key, OR launch through CLI. -fn main() -> io::Result<()> { - let args: Vec = env::args().collect(); - if args.len() == 2 && args[1] == "--help" { - let _ = Ps3decargs::parse_from(["", "--help"]); - return Ok(()); - } - setup_logging().expect("Failed to setup logging"); - let ps3_args = Ps3decargs::parse(); - if args.len() == 2 { - // drag & drop / single-arg path - let raw = &args[1]; - let dragdrop = raw.trim_matches(|c| c == '"' || c == '\''); - let path = Path::new(dragdrop); - let is_iso = path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("iso")) - .unwrap_or(false); +use args::Ps3decargs; +pub use ps3dec::{args, autodetect, logging, utils}; +pub use utils::{extract_regions, generate_iv, is_encrypted, read_exact_at, write_all_at}; - if is_iso { - let filename = path.file_stem().and_then(|f| f.to_str()).unwrap_or(""); - - info!("ISO path: {}", path.display()); - if let Ok(c) = path.canonicalize() { - info!("Canonical path: {}", c.display()); - } - info!("Filename used for key lookup: {}", filename); - - match detect_key(filename.to_string()) { - Ok(Some(key)) => { - info!("Detected key for {}: {}", filename, key); - ps3dec::decrypt(dragdrop.to_string(), &key, num_cpus::get(), None, None,DEFAULT_CHUNK)?; - } - _ => error!("No key found for {}", filename), - } - } else { - error!("The file must be an ISO file with .iso extension"); +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("Error: {e}"); + ExitCode::FAILURE } - } else if args.len() > 1 { - let ps3_args = Ps3decargs::parse(); - let p = Path::new(&ps3_args.iso); - let is_iso = p - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("iso")) - .unwrap_or(false); - if !is_iso { - error!("The file must be an ISO file with .iso extension"); - return Ok(()); - } - - let filename = p.file_stem().and_then(|f| f.to_str()).unwrap_or(""); - info!("ISO path: {}", p.display()); - if let Ok(c) = p.canonicalize() { - info!("Canonical path: {}", c.display()); - } - info!("Filename used for key lookup: {}", filename); - - if ps3_args.auto { - if let Ok(Some(key)) = detect_key(filename.to_string()) { - info!("Auto-detected key for {}: {}", filename, key); - ps3dec::decrypt( - ps3_args.iso, - &key, - ps3_args.tc, - ps3_args.output_dir, - ps3_args.output_name, - ps3_args.chunk_size - )?; - } else { - error!("No key could be auto-detected for {}", filename); - } - } else if let Some(dk) = ps3_args.dk { - if key_validation(&dk) { - info!("Using provided decryption key: {}", dk); - ps3dec::decrypt( - ps3_args.iso, - &dk, - ps3_args.tc, - ps3_args.output_dir, - ps3_args.output_name, - ps3_args.chunk_size, - )?; - } else { - error!("Error: Invalid PS3 decryption key format."); - } - } else { - error!("Error: Decryption key is required unless '--auto' is specified."); + } +} + +fn run() -> io::Result<()> { + let mut args = Ps3decargs::parse(); + let bare_paths = env::args_os().len() == args.iso.len() + 1; + if bare_paths { + for path in &mut args.iso { + *path = path.trim_matches(|c| c == '"' || c == '\'').to_owned(); } - - } else { - error!("Please provide an ISO file path. Use --help for more information."); - exit(0) } - - if !ps3_args.skip { - info!("Job done, press any button to exit..."); - let mut input_string = String::new(); - io::stdin().read_line(&mut input_string).expect("Failed to read line"); - info!("Ciao!"); + if args.dk.is_none() && (bare_paths || args.iso.len() != 1) { + args.auto = true; + } + let fullscreen = args.iso.len() != 1 + && io::stdin().is_terminal() + && io::stdout().is_terminal() + && io::stderr().is_terminal(); + if args.iso.is_empty() && !fullscreen { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "provide an ISO path, or open PS3Dec in a terminal to use the directory queue", + )); + } + let logs = fullscreen.then(logging::LogBuffer::default); + let progress = indicatif::MultiProgress::new(); + logging::setup_logging(progress.clone(), logs.clone()) + .map_err(|e| io::Error::other(e.to_string()))?; + ps3dec::queue::run(&args, &progress, logs.as_ref())?; + if !args.skip && !fullscreen { + info!("Job done, press Enter to exit..."); + + io::stdin().read_line(&mut String::new())?; } - Ok(()) -} \ No newline at end of file +} diff --git a/src/queue.rs b/src/queue.rs new file mode 100644 index 0000000..3ea43b8 --- /dev/null +++ b/src/queue.rs @@ -0,0 +1,638 @@ +use crate::args::Ps3decargs; +use crate::{ + autodetect::detect_key, cpu_pool, decrypt_file, logging::LogBuffer, output_path, + progress_message, progress_row, progress_style, ui, +}; +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressState}; +use log::{error, info}; +use std::collections::HashSet; +use std::fs; +use std::io::{self, IsTerminal}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +pub(crate) const REFRESH_INTERVAL: Duration = Duration::from_millis(300); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum State { + Waiting, + Queued, + Running, + Pausing, + Paused, + Finished, +} + +#[derive(Clone)] +pub(crate) struct Job { + pub id: usize, + pub input: PathBuf, + pub progress: ProgressBar, + state: State, + stop: Arc, + order: usize, + failure: Option, +} + +impl Job { + fn new(input: PathBuf, index: usize, state: State) -> Self { + let progress = progress_row(&input, index + 1, &progress_style()); + if state == State::Waiting { + progress.set_message(progress_message("Waiting")); + } + Self { + id: index, + input, + progress, + state, + stop: Arc::new(AtomicBool::new(false)), + order: index, + failure: None, + } + } + + pub fn active(&self) -> bool { + matches!(self.state, State::Running | State::Pausing) + } + + pub fn paused(&self) -> bool { + self.state == State::Paused + } + + fn movable(&self) -> bool { + !self.active() && !matches!(self.status().as_str(), "Done" | "Already done") + } + + pub fn status(&self) -> String { + if self.state == State::Pausing { + return "Pausing".to_owned(); + } + if self.active() && self.stop.load(Ordering::Relaxed) { + return "Stopping".to_owned(); + } + console::strip_ansi_codes(&self.progress.message()) + .trim() + .to_owned() + } +} + +pub(crate) struct Queue { + pub directory: PathBuf, + jobs: Mutex>, + shutdown: AtomicBool, +} + +impl Queue { + fn new(args: &Ps3decargs) -> io::Result { + let jobs = args + .iso + .iter() + .enumerate() + .map(|(index, input)| Job::new(PathBuf::from(input), index, State::Queued)) + .collect(); + Ok(Self { + directory: std::env::current_dir()?, + jobs: Mutex::new(jobs), + shutdown: AtomicBool::new(false), + }) + } + + pub fn snapshot(&self) -> Vec { + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()).clone(); + jobs.sort_by_key(|job| job.order); + jobs + } + + pub fn idle(&self) -> bool { + self.jobs + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .all(|job| matches!(job.state, State::Waiting | State::Paused | State::Finished)) + } + + pub fn shutting_down(&self) -> bool { + self.shutdown.load(Ordering::Relaxed) + } + + pub fn start(&self, index: usize) { + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + let Some(job) = jobs.get(index) else { + return; + }; + if self.shutting_down() + || job.active() + || matches!(job.status().as_str(), "Done" | "Already done") + { + return; + } + let job = &mut jobs[index]; + if matches!(job.state, State::Paused | State::Finished) { + let position = job.progress.position(); + job.progress.reset(); + job.progress.set_position(position); + } + job.stop.store(false, Ordering::Relaxed); + job.state = State::Queued; + job.progress.set_message(progress_message("Queued")); + } + + pub fn toggle_pause(&self, index: usize) { + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + if self.shutting_down() { + return; + } + let Some(job) = jobs.get_mut(index) else { + return; + }; + match job.state { + State::Running if !job.progress.is_finished() => { + job.state = State::Pausing; + job.stop.store(true, Ordering::Relaxed); + } + State::Waiting | State::Queued => { + job.state = State::Paused; + job.progress + .abandon_with_message(progress_message("Paused")); + } + State::Paused => { + drop(jobs); + self.start(index); + } + _ => {} + } + } + + pub fn move_job(&self, index: usize, up: bool) { + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + let Some(job) = jobs.get(index) else { + return; + }; + if self.shutting_down() || !job.movable() { + return; + } + let order = job.order; + let neighbor = jobs + .iter() + .enumerate() + .filter(|(_, job)| { + job.movable() + && if up { + job.order < order + } else { + job.order > order + } + }) + .min_by_key(|(_, job)| job.order.abs_diff(order)) + .map(|(index, _)| index); + if let Some(neighbor) = neighbor { + jobs[index].order = jobs[neighbor].order; + jobs[neighbor].order = order; + } + } + + pub fn cancel(&self, index: usize) { + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + let Some(job) = jobs.get_mut(index) else { + return; + }; + match job.state { + State::Running | State::Pausing if !job.progress.is_finished() => { + job.state = State::Running; + job.stop.store(true, Ordering::Relaxed); + } + State::Waiting | State::Queued | State::Paused => { + job.state = State::Finished; + job.progress + .abandon_with_message(progress_message("Stopped")); + } + _ => {} + } + } + + pub fn shutdown(&self) { + self.shutdown.store(true, Ordering::Relaxed); + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + for job in jobs.iter_mut() { + match job.state { + State::Running | State::Pausing => { + job.state = State::Running; + job.stop.store(true, Ordering::Relaxed); + } + State::Queued => { + job.state = State::Finished; + job.progress + .abandon_with_message(progress_message("Stopped")); + } + _ => {} + } + } + } + + fn next(&self) -> Option<(usize, Job)> { + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + if self.shutting_down() { + return None; + } + let index = jobs + .iter() + .enumerate() + .filter(|(_, job)| job.state == State::Queued) + .min_by_key(|(_, job)| job.order) + .map(|(index, _)| index)?; + let job = &mut jobs[index]; + job.state = State::Running; + job.progress.set_message(progress_message("Starting")); + Some((index, job.clone())) + } + + fn finish(&self, index: usize, result: io::Result<()>) { + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + let job = &mut jobs[index]; + if job.state == State::Pausing + && job.stop.load(Ordering::Relaxed) + && result + .as_ref() + .is_err_and(|e| e.kind() == io::ErrorKind::Interrupted) + { + job.state = State::Paused; + job.progress + .abandon_with_message(progress_message("Paused")); + let message = format!("Paused: {}", job.input.display()); + drop(jobs); + info!("{message}"); + return; + } + job.state = State::Finished; + job.failure = result.as_ref().err().map(io::Error::kind); + if let Err(e) = result { + let stopped = + e.kind() == io::ErrorKind::Interrupted && job.stop.load(Ordering::Relaxed); + job.progress + .abandon_with_message(progress_message(if stopped { "Stopped" } else { "Failed" })); + let message = format!("{}: {e}", job.input.display()); + drop(jobs); + error!("{message}"); + } + } + + pub fn discover(&self, args: &Ps3decargs) -> io::Result<()> { + let mut paths = Vec::new(); + for entry in fs::read_dir(&self.directory)? { + let entry = entry?; + let kind = match entry.file_type() { + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, + result => result?, + }; + let path = entry.path(); + if kind.is_file() + && path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("iso")) + && !entry + .file_name() + .to_string_lossy() + .to_ascii_lowercase() + .ends_with("_decrypted.iso") + { + paths.push(path); + } + } + paths.sort(); + let mut jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner()); + for input in paths { + let source = normalized(&input); + let output = normalized(&output_path(&input, args.output_dir.as_deref(), None)); + if jobs.iter().any(|job| { + normalized(&job.input) == source + || normalized(&output_path(&job.input, args.output_dir.as_deref(), None)) + == output + }) { + continue; + } + let index = jobs.len(); + jobs.push(Job::new(input, index, State::Waiting)); + } + Ok(()) + } +} + +pub fn run( + args: &Ps3decargs, + progress: &MultiProgress, + logs: Option<&LogBuffer>, +) -> io::Result<()> { + if args.iso.len() != 1 && (args.dk.is_some() || args.output_name.is_some()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "--dk and --output-name require a single ISO; batches look up each key separately", + )); + } + if args.tc == 0 || args.jobs == 0 || args.chunk_size == Some(0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "thread, job, and chunk counts must be positive", + )); + } + if let Some(dir) = &args.output_dir { + fs::create_dir_all(dir)?; + } + check_paths(args)?; + let pool = cpu_pool(args.tc)?; + + if let Some(logs) = logs { + progress.set_draw_target(ProgressDrawTarget::hidden()); + return run_tui(args, &pool, logs); + } + let interactive = io::stderr().is_terminal() && logs.is_none(); + let style = progress_style(); + let _header = interactive.then(|| { + let header = ProgressBar::hidden(); + header.set_prefix(" # ISO"); + header.set_message(progress_message("STATUS")); + header.set_style( + style + .clone() + .with_key("bar", |_: &ProgressState, out: &mut dyn std::fmt::Write| { + write!(out, "{:<16}", "PROGRESS").unwrap(); + }) + .with_key( + "percent", + |_: &ProgressState, _: &mut dyn std::fmt::Write| {}, + ) + .with_key( + "bytes_per_sec", + |_: &ProgressState, out: &mut dyn std::fmt::Write| { + write!(out, "SPEED").unwrap(); + }, + ) + .with_key("eta", |_: &ProgressState, out: &mut dyn std::fmt::Write| { + write!(out, "ETA").unwrap(); + }), + ); + let header = progress.add(header); + header.finish(); + header + }); + let rows: Vec<_> = args + .iso + .iter() + .enumerate() + .map(|(index, name)| { + let input = PathBuf::from(name); + let bar = progress.add(progress_row(&input, index + 1, &style)); + bar.tick(); + (input, bar) + }) + .collect(); + let stop = AtomicBool::new(false); + let plain = !io::stderr().is_terminal(); + let errors = process_queue(args, &rows, &pool, &stop, plain); + if !errors.is_empty() || stop.load(Ordering::Relaxed) { + progress.clear()?; + } + if stop.load(Ordering::Relaxed) { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "decryption stopped; reopen PS3Dec with the same ISOs to resume unfinished files automatically", + )); + } + if errors.is_empty() { + return Ok(()); + } + + Err(io::Error::other(format!( + "{} {} failed; reopen PS3Dec with the same ISOs to resume any unfinished files", + errors.len(), + if errors.len() == 1 { "ISO" } else { "ISOs" } + ))) +} + +fn run_tui(args: &Ps3decargs, pool: &rayon::ThreadPool, logs: &LogBuffer) -> io::Result<()> { + let queue = Queue::new(args)?; + let count = if args.sequential { 1 } else { args.jobs }; + thread::scope(|scope| { + let workers: Vec<_> = (0..count) + .map(|_| { + scope.spawn(|| { + while !queue.shutting_down() { + let Some((index, job)) = queue.next() else { + thread::sleep(REFRESH_INTERVAL); + continue; + }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_file(&job.input, args, pool, &job.progress, &job.stop) + })) + .unwrap_or_else(|_| { + queue.shutdown(); + Err(io::Error::other("decryption worker panicked")) + }); + queue.finish(index, result); + } + }) + }) + .collect(); + let display = ui::run(args, &queue, logs); + queue.shutdown(); + let mut panicked = false; + for worker in workers { + if worker.join().is_err() { + panicked = true; + } + } + display?; + if panicked { + return Err(io::Error::other("decryption worker panicked")); + } + Ok(()) + })?; + let rows = queue.snapshot(); + if rows + .iter() + .any(|job| job.failure == Some(io::ErrorKind::Interrupted)) + { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "decryption stopped; reopen PS3Dec with the same ISOs to resume unfinished files automatically", + )); + } + let failures = rows.iter().filter(|job| job.failure.is_some()).count(); + if failures > 0 { + return Err(io::Error::other(format!( + "{failures} {} failed; reopen PS3Dec with the same ISOs to resume any unfinished files", + if failures == 1 { "ISO" } else { "ISOs" } + ))); + } + Ok(()) +} + +fn process_queue( + args: &Ps3decargs, + rows: &[(PathBuf, ProgressBar)], + pool: &rayon::ThreadPool, + stop: &AtomicBool, + plain: bool, +) -> Vec { + let next = AtomicUsize::new(0); + let jobs = if args.sequential { 1 } else { args.jobs }.min(rows.len()); + let errors = thread::scope(|scope| { + let workers: Vec<_> = (0..jobs) + .map(|_| { + scope.spawn(|| { + let mut errors = Vec::new(); + while !stop.load(Ordering::Relaxed) { + let Some((input, bar)) = rows.get(next.fetch_add(1, Ordering::Relaxed)) + else { + break; + }; + if plain { + eprintln!("{}: starting", input.display()); + } + match run_file(input, args, pool, bar, stop) { + Err(e) => { + let stopped = e.kind() == io::ErrorKind::Interrupted + && stop.load(Ordering::Relaxed); + bar.abandon_with_message(progress_message(if stopped { + "Stopped" + } else { + "Failed" + })); + let message = format!("{}: {e}", input.display()); + error!("{message}"); + errors.push(message); + } + Ok(()) if plain => eprintln!("{}: {}", input.display(), bar.message()), + Ok(()) => { + // we good + } + } + } + errors + }) + }) + .collect(); + workers + .into_iter() + .flat_map(|worker| { + worker.join().unwrap_or_else(|_| { + error!("decryption worker panicked"); + vec!["decryption worker panicked".to_owned()] + }) + }) + .collect() + }); + for (_, bar) in rows.iter().filter(|(_, bar)| !bar.is_finished()) { + bar.abandon_with_message(progress_message(if stop.load(Ordering::Relaxed) { + "Stopped" + } else { + "Failed" + })); + } + errors +} + +fn run_file( + input: &Path, + args: &Ps3decargs, + pool: &rayon::ThreadPool, + progress: &indicatif::ProgressBar, + stop: &AtomicBool, +) -> io::Result<()> { + if !input + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("iso")) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "input must have an .iso extension", + )); + } + let metadata = fs::metadata(input).map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + let message = if input.is_relative() { + let directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + format!( + "file not found (current directory: {}); use an absolute path or change to the ISO directory", + directory.display() + ) + } else { + "file not found".to_owned() + }; + io::Error::new(e.kind(), message) + } else { + e + } + })?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "input must be a regular file", + )); + } + info!("ISO path: {}", input.display()); + let key = key_for(input, args)?; + let output = output_path( + input, + args.output_dir.as_deref(), + args.output_name.as_deref(), + ); + decrypt_file(input, &key, &output, args.chunk_size, pool, progress, stop) +} + +fn key_for(input: &Path, args: &Ps3decargs) -> io::Result { + if let Some(key) = &args.dk { + return Ok(key.clone()); + } + if !args.auto { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a key is required; use --dk or --auto", + )); + } + let name = input.file_stem().and_then(|s| s.to_str()).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "input filename is not valid UTF-8", + ) + })?; + detect_key(name.to_owned()) + .map_err(io::Error::other)? + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no matching key in keys/")) +} + +fn check_paths(args: &Ps3decargs) -> io::Result<()> { + let mut inputs = HashSet::new(); + let mut outputs = HashSet::new(); + for name in &args.iso { + let input = Path::new(name); + let output = output_path( + input, + args.output_dir.as_deref(), + args.output_name.as_deref(), + ); + if !inputs.insert(normalized(input)) || !outputs.insert(normalized(&output)) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("duplicate input or output path: {}", input.display()), + )); + } + } + Ok(()) +} + +fn normalized(path: &Path) -> PathBuf { + if let Ok(path) = fs::canonicalize(path) { + return path; + } + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent = fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()); + parent.join(path.file_name().unwrap_or(path.as_os_str())) +} diff --git a/src/recover.rs b/src/recover.rs new file mode 100644 index 0000000..98c7a14 --- /dev/null +++ b/src/recover.rs @@ -0,0 +1,362 @@ +use atomicwrites::{AllowOverwrite, AtomicFile, move_atomic}; +use fs2::FileExt; +use same_file::Handle; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use crate::utils::read_exact_at; + +#[derive(Deserialize, Serialize, PartialEq, Clone)] +#[serde(deny_unknown_fields)] +struct Source { + path: PathBuf, + size: u64, + modified: (u64, u32), + header: String, +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(deny_unknown_fields)] +struct Chunk { + start: u64, + digest: String, +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(deny_unknown_fields)] +struct Record { + version: u32, + source: Source, + output: PathBuf, + key: String, + next_offset: u64, + chunk: Option, +} + +pub(crate) struct Recovery { + input: File, + file: File, + part: PathBuf, + resume: PathBuf, + record: Record, + complete: bool, +} + +impl Recovery { + pub(crate) fn open( + input_path: &Path, + input: &File, + output: &Path, + key: &[u8], + ) -> io::Result { + Self::open_inner(input_path, input, output, key).map_err(|e| context(output, e)) + } + + fn open_inner(input_path: &Path, input: &File, output: &Path, key: &[u8]) -> io::Result { + let parent = output + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let output = fs::canonicalize(parent)?.join( + output + .file_name() + .ok_or_else(|| invalid("output has no filename"))?, + ); + let part = append(&output, ".part"); + let resume = append(&output, ".resume"); + let has_part = regular(&part)?; + let has_final = regular(&output)?; + let has_resume = regular(&resume)?; + let source = source(&fs::canonicalize(input_path)?, input)?; + let key = hex::encode(Sha256::digest(key)); + if !has_part && !has_final && has_resume { + return Err(invalid("checkpoint exists without an output")); + } + let fresh = !has_part && !has_final; + let path = if has_final { &output } else { &part }; + let file = OpenOptions::new() + .read(true) + .write(true) + .create_new(fresh) + .open(path)?; + // Lock the data inode before reading or changing its checkpoint. + FileExt::try_lock_exclusive(&file)?; + verify_path(path, &file)?; + if same_file(&file, input)? { + return Err(invalid("output is a hardlink to the input")); + } + // Recheck names after acquiring the lock: another writer may have published. + if regular(&part)? != (has_part || fresh) || regular(&output)? != has_final { + return Err(invalid( + "output paths changed while acquiring the lock; retry", + )); + } + if regular(&resume)? && fresh { + return Err(invalid( + "checkpoint appeared while creating the partial output", + )); + } + let record = if fresh { + Record { + version: 1, + source, + output, + key, + next_offset: 0, + chunk: None, + } + } else { + let record: Record = serde_json::from_reader(File::open(&resume)?.take(64 * 1024)) + .map_err(|e| invalid(format!("invalid checkpoint {}: {e}", resume.display())))?; + if record.version != 1 + || record.source != source + || record.output != output + || record.key != key + { + return Err(invalid( + "checkpoint does not match source, output, version, or key", + )); + } + record + }; + let recovery = Self { + input: input.try_clone()?, + file, + part, + resume, + record, + complete: has_final, + }; + recovery.validate_output()?; + if has_final && has_part { + let alias = File::open(&recovery.part)?; + if !same_file(&recovery.file, &alias)? { + return Err(invalid("both partial and final outputs exist")); + } + verify_path(&recovery.part, &alias)?; + // move_atomic may have linked the destination before unlinking the source. + fs::remove_file(&recovery.part)?; + } + if !has_final { + recovery.file.set_len(recovery.offset())?; + } + recovery.file.sync_all()?; + recovery.check_source()?; + // Also makes a recovered publication (and alias removal) directory-durable. + recovery.save(&recovery.record)?; + Ok(recovery) + } + + pub(crate) fn file(&self) -> &File { + &self.file + } + pub(crate) fn offset(&self) -> u64 { + self.record.next_offset + } + pub(crate) fn is_complete(&self) -> bool { + self.complete + } + + pub(crate) fn checkpoint( + &mut self, + next_offset: u64, + plaintext_chunk: &[u8], + ) -> io::Result<()> { + self.commit(next_offset, plaintext_chunk) + .map_err(|e| context(&self.resume, e)) + } + + fn commit(&mut self, next_offset: u64, plaintext_chunk: &[u8]) -> io::Result<()> { + if self.complete + || next_offset <= self.offset() + || next_offset > self.record.source.size + || !next_offset.is_multiple_of(2048) + || next_offset - self.offset() != plaintext_chunk.len() as u64 + { + return Err(invalid("invalid checkpoint offset or chunk length")); + } + self.check_source()?; + verify_path(&self.part, &self.file)?; + if regular(&self.record.output)? { + return Err(invalid("final output appeared during recovery")); + } + let len = self.file.metadata()?.len(); + if len < next_offset || len > self.record.source.size { + return Err(invalid( + "output length does not contain the committed chunk", + )); + } + let chunk = Chunk { + start: self.offset(), + digest: hex::encode(Sha256::digest(plaintext_chunk)), + }; + + self.file.sync_all()?; + self.check_source()?; + let mut record = self.record.clone(); + record.next_offset = next_offset; + record.chunk = Some(chunk); + self.save(&record)?; + self.record = record; + Ok(()) + } + + pub(crate) fn finish(self) -> io::Result<()> { + let output = self.record.output.clone(); + self.publish().map_err(|e| context(&output, e)) + } + + fn publish(self) -> io::Result<()> { + self.check_source()?; + if self.offset() != self.record.source.size { + return Err(invalid("cannot publish an incomplete output")); + } + self.validate_output()?; + self.file.sync_all()?; + if self.complete { + verify_path(&self.record.output, &self.file)?; + return self.save(&self.record); + } + verify_path(&self.part, &self.file)?; + if regular(&self.record.output)? { + return Err(invalid("final output already exists")); + } + move_atomic(&self.part, &self.record.output)?; + self.save(&self.record) + } + + fn validate_output(&self) -> io::Result<()> { + let offset = self.offset(); + let len = self.file.metadata()?.len(); + if !offset.is_multiple_of(2048) + || offset > self.record.source.size + || len < offset + || len > self.record.source.size + { + return Err(invalid("invalid checkpoint offset or truncated output")); + } + if self.complete && (offset != self.record.source.size || len != offset) { + return Err(invalid("final output does not have a complete checkpoint")); + } + let Some(chunk) = &self.record.chunk else { + if offset != 0 { + return Err(invalid("checkpoint is missing its committed chunk")); + } + return Ok(()); + }; + if chunk.start % 2048 != 0 + || chunk.start >= offset + || hash_range(&self.file, chunk.start, offset - chunk.start)? != chunk.digest + { + return Err(invalid("last committed chunk is invalid or has changed")); + } + Ok(()) + } + + fn check_source(&self) -> io::Result<()> { + if source(&self.record.source.path, &self.input)? != self.record.source { + return Err(invalid("source changed during recovery")); + } + Ok(()) + } + + fn save(&self, record: &Record) -> io::Result<()> { + regular(&self.resume)?; + AtomicFile::new(&self.resume, AllowOverwrite) + .write(|file| -> io::Result<()> { + serde_json::to_writer(file, record).map_err(|e| { + io::Error::new(e.io_error_kind().unwrap_or(io::ErrorKind::InvalidData), e) + }) + }) + .map_err(|e| context(&self.resume, io::Error::from(e))) + } +} + +fn source(path: &Path, file: &File) -> io::Result { + verify_path(path, file)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() % 2048 != 0 { + return Err(invalid("source must be a regular, sector-aligned file")); + } + let modified = metadata + .modified()? + .duration_since(UNIX_EPOCH) + .map_err(io::Error::other)?; + let header = hash_range(file, 0, metadata.len().min(64 * 1024))?; + let after = file.metadata()?; + if after.len() != metadata.len() || after.modified()? != metadata.modified()? { + return Err(invalid("source changed while checking its header")); + } + Ok(Source { + path: path.to_path_buf(), + size: metadata.len(), + modified: (modified.as_secs(), modified.subsec_nanos()), + header, + }) +} + +fn hash_range(file: &File, mut start: u64, mut len: u64) -> io::Result { + let mut hash = Sha256::new(); + let mut buffer = [0; 8192]; + while len != 0 { + let count = len.min(buffer.len() as u64) as usize; + read_exact_at(file, &mut buffer[..count], start)?; + hash.update(&buffer[..count]); + start += count as u64; + len -= count as u64; + } + Ok(hex::encode(hash.finalize())) +} + +fn regular(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(true), + Ok(_) => Err(invalid(format!( + "refusing non-regular path {}", + path.display() + ))), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(context(path, e)), + } +} + +fn verify_path(path: &Path, file: &File) -> io::Result<()> { + if !regular(path)? || Handle::from_path(path)? != Handle::from_file(file.try_clone()?)? { + return Err(invalid(format!( + "file identity changed: {}", + path.display() + ))); + } + Ok(()) +} + +fn same_file(a: &File, b: &File) -> io::Result { + Ok(Handle::from_file(a.try_clone()?)? == Handle::from_file(b.try_clone()?)?) +} + +fn append(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) +} + +fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} +fn context(path: &Path, error: io::Error) -> io::Error { + io::Error::new( + error.kind(), + if error.kind() == io::ErrorKind::StorageFull { + format!( + "disk full while saving recovery data for {}; free space and reopen PS3Dec to resume", + path.display() + ) + } else { + format!("recovery {}: {error}", path.display()) + }, + ) +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..a2e2767 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,267 @@ +use crate::{ + args::Ps3decargs, + logging::LogBuffer, + queue::{Job, REFRESH_INTERVAL}, +}; +use indicatif::{HumanBytes, HumanDuration}; +use ratatui::{ + Frame, + crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + layout::{Constraint, Layout}, + style::{Color, Style, Stylize}, + text::Line, + widgets::{Block, BorderType, Cell, Paragraph, Row, Table, TableState}, +}; +use std::{io, time::Instant}; + +pub(crate) fn run( + args: &Ps3decargs, + queue: &crate::queue::Queue, + logs: &LogBuffer, +) -> io::Result<()> { + let mut selection = TableState::default(); + let mut selected_id = None; + let result = (|| { + let mut terminal = ratatui::try_init()?; + let mut next_scan = Instant::now(); + loop { + let stopping = queue.shutting_down(); + if !stopping && Instant::now() >= next_scan { + queue.discover(args)?; + next_scan = Instant::now() + REFRESH_INTERVAL; + } + let rows = queue.snapshot(); + let selected = selected_id + .and_then(|id| rows.iter().position(|job| job.id == id)) + .or_else(|| { + (!rows.is_empty()) + .then(|| selection.selected().unwrap_or(0).min(rows.len() - 1)) + }); + selection.select(selected); + selected_id = selected.map(|index| rows[index].id); + let done = queue.idle(); + terminal.draw(|frame| render(frame, args, queue, &rows, logs, &mut selection))?; + if done + && (stopping + || (args.skip + && !args.iso.is_empty() + && !rows.iter().any(|job| job.active() || job.paused()))) + { + return Ok(()); + } + let timeout = if stopping { + REFRESH_INTERVAL + } else { + next_scan.saturating_duration_since(Instant::now()) + }; + if !event::poll(timeout)? { + continue; + } + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind == KeyEventKind::Release { + continue; + } + let quit = matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) + || (key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL)); + if quit { + if queue.idle() { + return Ok(()); + } + queue.shutdown(); + continue; + } + if key.code == KeyCode::Enter && queue.idle() { + return Ok(()); + } + if let Some(current) = selected { + let id = rows[current].id; + match key.code { + KeyCode::Up => { + let index = current.saturating_sub(1); + selection.select(Some(index)); + selected_id = Some(rows[index].id); + } + KeyCode::Down => { + let index = (current + 1).min(rows.len() - 1); + selection.select(Some(index)); + selected_id = Some(rows[index].id); + } + KeyCode::Char('s') if !stopping => queue.start(id), + KeyCode::Char('p') if !stopping => queue.toggle_pause(id), + KeyCode::Char('c') => queue.cancel(id), + KeyCode::Char('+') | KeyCode::Char('=') if !stopping => { + queue.move_job(id, true) + } + KeyCode::Char('-') if !stopping => queue.move_job(id, false), + _ => {} + } + } + } + })(); + let restored = ratatui::try_restore(); + result.and(restored) +} + +fn render( + frame: &mut Frame, + args: &Ps3decargs, + queue: &crate::queue::Queue, + rows: &[Job], + logs: &LogBuffer, + selection: &mut TableState, +) { + let directory = &queue.directory; + let stopping = queue.shutting_down(); + let done = queue.idle(); + let area = frame.area(); + if area.width < 80 || area.height < 12 { + frame.render_widget( + Paragraph::new("Resize to at least 80 × 12. q / Ctrl+C stops safely.").cyan(), + area, + ); + return; + } + let log_height = (area.height / 3).clamp(4, 10); + let [title, files, log_area, footer] = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(4), + Constraint::Length(log_height), + Constraint::Length(2), + ]) + .areas(area); + let completed = rows + .iter() + .filter(|job| matches!(job.status().as_str(), "Done" | "Already done")) + .count(); + let failed = rows.iter().filter(|job| job.status() == "Failed").count(); + let jobs = if args.sequential { 1 } else { args.jobs }; + let heading = Line::from(format!( + " PS3DEC · {} ISOs · {jobs} jobs · {} CPU threads · {completed} done / {failed} failed", + rows.len(), args.tc, + )).cyan().bold(); + frame.render_widget( + Paragraph::new(vec![ + heading, + Line::from(format!(" {}", directory.display())).dark_gray(), + ]), + title, + ); + + let name_width = usize::from(files.width).saturating_sub(60); + let table_rows = rows.iter().enumerate().map(|(index, job)| { + let input = &job.input; + let bar = &job.progress; + let message = job.status(); + let color = match message.as_str() { + "Done" | "Already done" => Color::Green, + "Failed" => Color::Red, + "Resuming" | "Pausing" | "Paused" | "Stopped" | "Stopping" => Color::Yellow, + "Waiting" | "Queued" => Color::DarkGray, + _ => Color::Cyan, + }; + let name = input + .file_name() + .unwrap_or(input.as_os_str()) + .to_string_lossy(); + let name = name.replace(char::is_control, " "); + let name = console::truncate_str(&name, name_width, "…").into_owned(); + let length = bar.length().unwrap_or(1).max(1); + let fraction = (bar.position() as f64 / length as f64).clamp(0.0, 1.0); + let filled = (fraction * 12.0) as usize; + let progress = format!( + "{:>3}% {}{}", + (fraction * 100.0) as u8, + "━".repeat(filled), + "─".repeat(12 - filled) + ); + let active = job.active(); + let rate = bar.per_sec(); + let speed = if active && rate > 0.0 { + format!("{}/s", HumanBytes(rate as u64)) + } else { + "--".to_owned() + }; + let eta = if active && rate > 0.0 { + format!("{:#}", HumanDuration(bar.eta())) + } else { + "--".to_owned() + }; + Row::new(vec![ + Cell::from((index + 1).to_string()), + Cell::from(name), + Cell::from(message).fg(color), + Cell::from(progress).fg(color), + Cell::from(speed), + Cell::from(eta), + ]) + }); + let table = Table::new( + table_rows, + [ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(12), + Constraint::Length(18), + Constraint::Length(12), + Constraint::Length(8), + ], + ) + .header( + Row::new(["#", "ISO", "STATUS", "PROGRESS", "SPEED", "ETA"]) + .cyan() + .bold(), + ) + .row_highlight_style(Style::default().bg(Color::DarkGray)) + .block(panel(" Decryption queue ")); + frame.render_stateful_widget(table, files, selection); + + let available = usize::from(log_area.height.saturating_sub(2)); + let lines: Vec = logs + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .rev() + .take(available) + .rev() + .map(|text| { + let color = if text.contains("[ERROR]") { + Color::Red + } else if text.contains("[WARN]") { + Color::Yellow + } else { + Color::Gray + }; + Line::from(text.clone()).fg(color) + }) + .collect(); + frame.render_widget(Paragraph::new(lines).block(panel(" Logs ")), log_area); + + let hint = if stopping { + " Stopping after current chunks are synced…" + } else if done { + " ↑/↓ select · s start/resume · p pause · c stop · +/- move · Enter/q/Esc close" + } else { + " ↑/↓ select · s start/resume · p pause · c stop · +/- move · q/Esc/Ctrl+C stop" + }; + let filename = selection + .selected() + .and_then(|index| rows.get(index)) + .map_or_else(String::new, |job| job.input.display().to_string()); + frame.render_widget( + Paragraph::new(vec![ + Line::from(hint).cyan(), + Line::from(filename).dark_gray(), + ]), + footer, + ); +} + +fn panel(title: &str) -> Block<'_> { + Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Color::DarkGray) + .title(title) +} diff --git a/src/utils.rs b/src/utils.rs index bb7b8d7..e018f54 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,29 +1,25 @@ use aes::cipher::consts::U16; use aes::cipher::generic_array::GenericArray; -use chrono::Local; -use log::{info, warn, LevelFilter}; +use log::{info, warn}; use std::fs::File; -use std::{fs, io}; -use std::io::{ Read, Seek, SeekFrom}; -use log4rs::{ - append::{console::ConsoleAppender, file::FileAppender}, - config::{Appender, Config, Root}, - encode::pattern::PatternEncoder, -}; - +use std::io; +use std::io::{Read, Seek, SeekFrom}; #[cfg(unix)] use std::os::unix::fs::FileExt as _; #[cfg(windows)] use std::os::windows::fs::FileExt as _; -use std::path::Path; - #[cfg(unix)] pub fn read_exact_at(file: &File, mut buf: &mut [u8], mut off: u64) -> io::Result<()> { while !buf.is_empty() { - let n = file.read_at(buf, off)?; - if n == 0 { return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read_at=0")); } + let n = match file.read_at(buf, off) { + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read_at=0")); + } buf = &mut buf[n..]; off += n as u64; } @@ -32,8 +28,13 @@ pub fn read_exact_at(file: &File, mut buf: &mut [u8], mut off: u64) -> io::Resul #[cfg(windows)] pub fn read_exact_at(file: &File, mut buf: &mut [u8], mut off: u64) -> io::Result<()> { while !buf.is_empty() { - let n = file.seek_read(buf, off)?; - if n == 0 { return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "seek_read=0")); } + let n = match file.seek_read(buf, off) { + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "seek_read=0")); + } buf = &mut buf[n..]; off += n as u64; } @@ -43,8 +44,13 @@ pub fn read_exact_at(file: &File, mut buf: &mut [u8], mut off: u64) -> io::Resul #[cfg(unix)] pub fn write_all_at(file: &File, mut buf: &[u8], mut off: u64) -> io::Result<()> { while !buf.is_empty() { - let n = file.write_at(buf, off)?; - if n == 0 { return Err(io::Error::new(io::ErrorKind::WriteZero, "write_at=0")); } + let n = match file.write_at(buf, off) { + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Err(io::Error::new(io::ErrorKind::WriteZero, "write_at=0")); + } buf = &buf[n..]; off += n as u64; } @@ -53,15 +59,19 @@ pub fn write_all_at(file: &File, mut buf: &[u8], mut off: u64) -> io::Result<()> #[cfg(windows)] pub fn write_all_at(file: &File, mut buf: &[u8], mut off: u64) -> io::Result<()> { while !buf.is_empty() { - let n = file.seek_write(buf, off)?; - if n == 0 { return Err(io::Error::new(io::ErrorKind::WriteZero, "seek_write=0")); } + let n = match file.seek_write(buf, off) { + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Err(io::Error::new(io::ErrorKind::WriteZero, "seek_write=0")); + } buf = &buf[n..]; off += n as u64; } Ok(()) } - pub struct Region { start: u64, end: u64, @@ -105,66 +115,47 @@ pub fn is_encrypted(regions: &[Region], sector: u64, sector_data: &[u8]) -> bool regions.iter().any(|r| sector >= r.start && sector < r.end) } - - // Splitting the cake pub fn extract_regions(reader: &mut R) -> io::Result> { - let mut header = [0u8; 4096]; + let mut header = [0u8; 2048]; reader.seek(SeekFrom::Start(0))?; reader.read_exact(&mut header)?; let num_normal_regions = u32::from_be_bytes(header[0..4].try_into().unwrap()) as usize; - let regions_count = (num_normal_regions * 2) - 1; - let mut regions = Vec::with_capacity(regions_count); - - let mut is_encrypted = false; - for i in 0..regions_count { - let region_offset = 4 + i * 8; - let start_sector = - u32::from_be_bytes(header[region_offset..region_offset + 4].try_into().unwrap()); - let end_sector = u32::from_be_bytes( + if num_normal_regions == 0 { + return Err(io::Error::other("invalid region map: zero regions")); + } + if num_normal_regions > (header.len() - 8) / 8 { + return Err(io::Error::other( + "region map too large for 2048-byte sector", + )); + } + let mut regions = Vec::with_capacity(num_normal_regions - 1); + + let mut previous_end = 0u64; + for i in 0..num_normal_regions { + let region_offset = 8 + i * 8; + let start = + u32::from_be_bytes(header[region_offset..region_offset + 4].try_into().unwrap()) as u64; + let inclusive_end = u32::from_be_bytes( header[region_offset + 4..region_offset + 8] .try_into() .unwrap(), - ); - - regions.push(Region { - start: start_sector as u64, - end: end_sector as u64, - }); - - is_encrypted = !is_encrypted; + ) as u64; + let end = inclusive_end + 1; + + if start >= end || (i > 0 && start < previous_end) { + return Err(io::Error::other( + "invalid region map: reversed or overlapping regions", + )); + } + if i > 0 && previous_end < start { + regions.push(Region { + start: previous_end, + end: start, + }); + } + previous_end = end; } Ok(regions) } - - -pub fn setup_logging() -> Result<(), Box> { - let log_dir = Path::new("log"); - fs::create_dir_all(log_dir)?; - let now = Local::now(); - let log_file_name = format!("log/{}.log", now.format("%Y-%m-%d_%H-%M-%S")); - - let fmt = "{d(%Y-%m-%d %H:%M:%S)} [{l}] - {m}\n"; - - let stdout = ConsoleAppender::builder() - .encoder(Box::new(PatternEncoder::new(fmt))) - .build(); - - let logfile = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new(fmt))) - .build(log_file_name)?; - - let config = Config::builder() - .appender(Appender::builder().build("stdout", Box::new(stdout))) - .appender(Appender::builder().build("logfile", Box::new(logfile))) - .build( - Root::builder() - .appender("stdout") - .appender("logfile") - .build(LevelFilter::Trace), - )?; - - log4rs::init_config(config)?; - Ok(()) -} diff --git a/tests/e2e.py b/tests/e2e.py new file mode 100644 index 0000000..a88704e --- /dev/null +++ b/tests/e2e.py @@ -0,0 +1,167 @@ +"""Run the actual executable against one fixed encrypted ISO.""" + +import hashlib +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BINARY = ROOT / "target" / "debug" / ("ps3dec.exe" if os.name == "nt" else "ps3dec") +TESTS = ROOT / "tests" + + +def digest(path): + with path.open("rb") as file: + return hashlib.file_digest(file, "sha256").digest() + + +def run(directory, *args, succeeds=True): + result = subprocess.run( + [str(BINARY), *map(str, args)], cwd=directory, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=30, check=False, + ) + assert (result.returncode == 0) == succeeds, result.stdout + result.stderr + if not succeeds: + assert "Job done" not in result.stderr, result.stderr + return result.stderr + + +def check_output(output): + assert digest(output) == EXPECTED_HASH, f"wrong output bytes: {output}" + assert not Path(str(output) + ".part").exists() + record = json.loads(Path(str(output) + ".resume").read_text()) + assert record["next_offset"] == EXPECTED.stat().st_size + assert record["key"] != KEY + + +def single_file(directory): + run(directory, "Fake.iso", "--dk", KEY, "--chunk-size", 1, "--tc", 2, "--skip") + output = directory / "Fake.iso_decrypted.iso" + check_output(output) + assert "Already done" in run(directory, "Fake.iso", "--dk", KEY, "--skip") + check_output(output) + shutil.copyfile(directory / "Fake.iso", directory / "Name with spaces.iso") + shutil.copyfile(directory / "keys/Fake.dkey", directory / "keys/Name with spaces.dkey") + run(directory, '"Name with spaces.iso"') + check_output(directory / "Name with spaces.iso_decrypted.iso") + + +def batch_files(directory): + shutil.copyfile(directory / "Fake.iso", directory / "Second.iso") + shutil.copyfile(directory / "keys/Fake.dkey", directory / "keys/Second.dkey") + for mode, flags in (("default", ()), ("sequential", ("--sequential",)), ("parallel", ("--jobs", 2))): + run(directory, "Fake.iso", "Second.iso", "--output-dir", mode, + "--chunk-size", 1, "--tc", 2, "--skip", *flags) + check_output(directory / mode / "Fake_decrypted.iso") + check_output(directory / mode / "Second_decrypted.iso") + assert "Already done" in run(directory, "Fake.iso", "Second.iso", + "--output-dir", mode, "--skip", *flags) + + +def errors_and_orphans(directory): + shutil.copyfile(directory / "Fake.iso", directory / "Bad.iso") + shutil.copyfile(directory / "keys/Fake.dkey", directory / "keys/Bad.dkey") + with (directory / "Bad.iso").open("r+b") as file: + file.write(bytes(4)) + orphan = directory / "Missing.iso_decrypted.iso.resume" + orphan.write_text("orphan checkpoint; leave untouched") + run(directory, "Bad.iso", "Fake.iso", "Missing.iso", "--jobs", 2, + "--tc", 2, "--skip", succeeds=False) + check_output(directory / "Fake.iso_decrypted.iso") + assert orphan.read_text() == "orphan checkpoint; leave untouched" + run(directory, "Fake.iso", "./Fake.iso", "--skip", succeeds=False) + existing = directory / "unrelated.iso" + existing.write_bytes(b"do not overwrite") + run(directory, "Fake.iso", "--dk", KEY, "--output-name", "unrelated", + "--skip", succeeds=False) + assert existing.read_bytes() == b"do not overwrite" + run(directory, "Fake.iso", "Bad.iso", "--dk", KEY, "--skip", succeeds=False) + run(directory, "Fake.iso", "--auto", "--chunk-size", 0, "--skip", succeeds=False) + + +def automatic_recovery(directory): + iso = directory / "Fake.iso" + output = directory / "Fake.iso_decrypted.iso" + part = Path(str(output) + ".part") + resume = Path(str(output) + ".resume") + child = subprocess.Popen( + [str(BINARY), iso.name, "--dk", KEY, "--chunk-size", "1", "--tc", "1", "--skip"], + cwd=directory, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if resume.exists() and 0 < json.loads(resume.read_text())["next_offset"] < iso.stat().st_size: + break + assert child.poll() is None, "fixture finished before interruption" + time.sleep(0.002) + else: + raise AssertionError("no committed checkpoint within 15 seconds") + child.send_signal(signal.SIGSTOP) + os.waitpid(child.pid, os.WUNTRACED) + run(directory, iso.name, "--dk", KEY, "--tc", 1, "--skip", succeeds=False) + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=5) + + saved_record = resume.read_bytes() + offset = json.loads(saved_record)["next_offset"] + assert 0 < offset < iso.stat().st_size + prefix = part.read_bytes()[:offset] + with EXPECTED.open("rb") as reference: + assert prefix == reference.read(offset) + run(directory, iso.name, "--dk", "ff" * 16, "--skip", succeeds=False) + assert resume.read_bytes() == saved_record + resume.write_text("{broken checkpoint") + run(directory, iso.name, "--dk", KEY, "--skip", succeeds=False) + resume.write_bytes(saved_record) + with part.open("r+b") as file: + file.truncate(offset - 2048) + run(directory, iso.name, "--dk", KEY, "--skip", succeeds=False) + part.write_bytes(prefix) + with part.open("r+b") as file: + file.seek(offset - 1) + file.write(bytes([prefix[-1] ^ 1])) + run(directory, iso.name, "--dk", KEY, "--skip", succeeds=False) + part.write_bytes(prefix) + source_stat = iso.stat() + os.utime(iso, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns + 1_000_000_000)) + run(directory, iso.name, "--dk", KEY, "--skip", succeeds=False) + os.utime(iso, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns)) + with part.open("ab") as file: + file.write(b"uncommitted bytes" * 31) + run(directory, iso.name, "--dk", KEY, "--chunk-size", 2, "--tc", 2, "--skip") + check_output(output) + logs = "".join(path.read_text() for path in (directory / "log").glob("*.log")) + assert f"from byte {offset}" in logs, "did not start at the saved checkpoint" + + output.rename(part) + run(directory, iso.name, "--dk", KEY, "--skip") + check_output(output) + + +if not BINARY.is_file(): + raise SystemExit("Run cargo build --locked --bin ps3dec first.") +subprocess.run([sys.executable, "-B", str(ROOT / "tests/fake_iso.py")], check=True) +KEY = (TESTS / "keys/Fake.dkey").read_text().strip() +EXPECTED = TESTS / "Fake.expected.iso" +EXPECTED_HASH = digest(EXPECTED) +assert digest(TESTS / "Fake.iso") != EXPECTED_HASH +checks = [single_file, batch_files, errors_and_orphans] +if os.name == "posix": + checks.append(automatic_recovery) +for check in checks: + with tempfile.TemporaryDirectory(prefix="ps3dec-e2e-") as directory: + directory = Path(directory) + shutil.copyfile(TESTS / "Fake.iso", directory / "Fake.iso") + shutil.copytree(TESTS / "keys", directory / "keys") + check(directory) + print(f"PASS {check.__name__}", flush=True) +print(f"{len(checks)} end-to-end checks passed") diff --git a/tests/fake_iso.py b/tests/fake_iso.py new file mode 100644 index 0000000..d918df6 --- /dev/null +++ b/tests/fake_iso.py @@ -0,0 +1,64 @@ +"""Build the one fixed test ISO and its plaintext reference.""" + +import mmap +import shutil +import struct +import subprocess +import tempfile +from pathlib import Path + +DIRECTORY = Path(__file__).resolve().parent +ISO = DIRECTORY / "Fake.iso" +EXPECTED = DIRECTORY / "Fake.expected.iso" +KEY = (DIRECTORY / "keys/Fake.dkey").read_text().strip() +SECTOR = 2048 + + +mkisofs = shutil.which("genisoimage") or shutil.which("mkisofs") +if not mkisofs or not shutil.which("openssl"): + raise SystemExit("Install genisoimage (or mkisofs) and OpenSSL first.") + +marker = b"PS3DEC_ENCRYPTED_TEST_PAYLOAD\x00" +payload = marker + bytes(i % 251 for i in range(4 * SECTOR - len(marker))) +with tempfile.TemporaryDirectory() as directory: + tree = Path(directory) + usrdir = tree / "PS3_GAME" / "USRDIR" + usrdir.mkdir(parents=True) + (usrdir / "PAYLOAD.BIN").write_bytes(payload) + (tree / "PS3_GAME" / "CONTROL.TXT").write_text("This stays plaintext.\n") + with (usrdir / "PADDING.BIN").open("wb") as padding: + padding.truncate(64 * 1024 * 1024) + subprocess.run( + [mkisofs, "-quiet", "-udf", "-iso-level", "3", "-V", "PS3DEC_TEST", + "-o", str(EXPECTED), str(tree)], check=True, capture_output=True, + ) + +with EXPECTED.open("r+b") as disc: + with mmap.mmap(disc.fileno(), 0, access=mmap.ACCESS_READ) as image: + offset = image.find(payload) + assert offset > 0 and offset % SECTOR == 0 + assert image.find(payload, offset + 1) == -1 + assert image[16 * SECTOR + 1:16 * SECTOR + 6] == b"CD001" + assert b"NSR02" in image[16 * SECTOR:32 * SECTOR] + last_sector = len(image) // SECTOR - 1 + first = offset // SECTOR + end = first + len(payload) // SECTOR - 1 + assert end < last_sector + header = bytearray(SECTOR) + struct.pack_into(">6I", header, 0, 2, 0, 0, first - 1, end + 1, last_sector) + disc.seek(0) + disc.write(header) + disc.write(b"PlayStation3" + bytes(4) + b"TEST-00001".ljust(32, b" ") + bytes(SECTOR - 48)) + +shutil.copyfile(EXPECTED, ISO) +with EXPECTED.open("rb") as plain, ISO.open("r+b") as disc: + for sector in range(first, end + 1): + plain.seek(sector * SECTOR) + encrypted = subprocess.run( + ["openssl", "enc", "-aes-128-cbc", "-nopad", "-K", KEY, + "-iv", sector.to_bytes(16, "big").hex()], + input=plain.read(SECTOR), check=True, capture_output=True, + ).stdout + disc.seek(sector * SECTOR) + disc.write(encrypted) +print("Generated tests/Fake.iso and its plaintext reference") diff --git a/tests/keys/Fake.dkey b/tests/keys/Fake.dkey new file mode 100644 index 0000000..8bf836e --- /dev/null +++ b/tests/keys/Fake.dkey @@ -0,0 +1 @@ +000102030405060708090a0b0c0d0e0f \ No newline at end of file