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