From 40315135740df59fe6ca424e2f8d7fb1b4782d13 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:45:05 -0700 Subject: [PATCH] audit: module-by-module code audit fixes and test coverage Lands the code-audit branches for aacs, css, keys, labels, udf, ifo and dvd, bdnav/mpls/clpi, disc/bluray, extract, io, scsi, the mkv, mp4, network and mpg muxers, the audio and video codecs, and CI. Fixes include MKB and FMTS validation, UDF bounds, IFO fallback to the BUP, DTS Express framing, MPEG-2 field pairing and mpg re-basing; the rest pins existing behaviour with tests. --- .github/dependabot.yml | 17 +- .github/workflows/ci.yml | 58 +- .github/workflows/ffmpeg-validation.yml | 6 +- .github/workflows/qa.yml | 43 +- .github/workflows/release.yml | 25 +- .github/workflows/scorecard.yml | 6 +- Cargo.lock | 12 - Cargo.toml | 9 +- LICENSES/LicenseRef-ISO-dist10.txt | 11 + LICENSES/MIT.txt | 21 + REUSE.toml | 19 +- benches/sgio_read.rs | 2 +- build.rs | 16 +- examples/corpus_titledump.rs | 8 +- src/aacs/content.rs | 37 +- src/aacs/derive.rs | 80 +- src/aacs/index_select.rs | 2 +- src/aacs/inf.rs | 148 ++-- src/aacs/mod.rs | 13 +- src/aacs/provider.rs | 20 + src/aacs/resolve.rs | 137 ++- src/aacs/segment.rs | 9 +- src/aacs/segment_key.rs | 1 + src/aacs/types.rs | 4 +- src/aacs/variant.rs | 232 +++-- src/bdnav/bdjo.rs | 8 +- src/bdnav/index.rs | 24 +- src/bdnav/mobj.rs | 15 +- src/bdnav/mod.rs | 4 + src/bdnav/vm.rs | 155 +++- src/clpi.rs | 88 +- src/consts.rs | 2 + src/css/keyless.rs | 43 +- src/css/lfsr.rs | 38 +- src/css/mod.rs | 163 +++- src/decrypt.rs | 176 +++- src/diag.rs | 92 +- src/dirimage/encode.rs | 7 +- src/dirimage/layout.rs | 76 +- src/dirimage/mod.rs | 13 +- src/dirimage/tests.rs | 381 ++++++++ src/disc/bluray.rs | 706 +++++++++++++-- src/disc/dvd.rs | 144 ++- src/disc/encrypt.rs | 7 +- src/disc/extract.rs | 623 ++++++++++--- src/disc/hddvd.rs | 441 +++++++++- src/disc/mod.rs | 245 ++++-- src/disc/pgs_forced_probe.rs | 71 +- src/disc/profile.rs | 21 + src/disc/scan_order_tests.rs | 12 +- src/drive/allow.rs | 7 +- src/drive/mod.rs | 195 ++++- src/drive/stop_tests.rs | 25 + src/dvdnav/nav.rs | 194 +++-- src/dvdnav/vmcmd.rs | 19 +- src/error.rs | 87 +- src/halt.rs | 16 +- src/halt/tests.rs | 16 +- src/harness.rs | 35 +- src/identity.rs | 95 +- src/ifo.rs | 523 ++++++++--- src/io/bounded.rs | 37 +- src/io/byte_prefetcher.rs | 165 +++- src/io/file_sector_source/linux.rs | 3 + src/io/file_sector_source/macos.rs | 1 + src/io/file_sector_source/mod.rs | 41 +- src/io/flush.rs | 25 +- src/io/flush/tests.rs | 140 ++- src/io/image_writer.rs | 41 +- src/io/pipeline.rs | 78 +- src/io/sink/mod.rs | 90 +- src/io/sink/socket.rs | 22 +- src/io/writeback/linux.rs | 55 +- src/io/writeback_file/flusher.rs | 99 ++- src/io/writeback_file/linux.rs | 9 +- src/io/writeback_file/macos.rs | 6 +- src/io/writeback_file/mod.rs | 137 ++- src/io/writeback_file/other.rs | 3 +- src/io/writeback_file/windows.rs | 3 +- src/keys/arrival.rs | 11 +- src/keys/fmts.rs | 372 +++++++- src/keys/mod.rs | 21 +- src/keys/resolve.rs | 48 +- src/keys/tests.rs | 167 +++- src/keysource.rs | 126 ++- src/labels/bdj_feature.rs | 29 +- src/labels/bdmt.rs | 136 ++- src/labels/class_reader.rs | 184 +++- src/labels/clpi_audit.rs | 34 +- src/labels/criterion.rs | 56 +- src/labels/ctrm.rs | 102 ++- src/labels/dbp.rs | 110 ++- src/labels/deluxe.rs | 1057 +++++++++++++++++++---- src/labels/fox.rs | 26 +- src/labels/jar.rs | 312 ++++++- src/labels/mod.rs | 540 ++++-------- src/labels/mpls_universal.rs | 54 +- src/labels/paramount.rs | 26 +- src/labels/pixelogic.rs | 7 +- src/labels/png_filenames.rs | 7 + src/labels/text.rs | 17 +- src/labels/vocab.rs | 67 +- src/labels/xml.rs | 2 + src/mpls.rs | 164 +++- src/mux/codec/ac3.rs | 86 +- src/mux/codec/adts.rs | 211 ++++- src/mux/codec/audio_frames.rs | 14 +- src/mux/codec/coding.rs | 39 +- src/mux/codec/dts.rs | 311 ++++++- src/mux/codec/dvdsub.rs | 42 +- src/mux/codec/flac.rs | 36 + src/mux/codec/h264.rs | 75 +- src/mux/codec/hevc.rs | 146 +++- src/mux/codec/lpcm.rs | 68 +- src/mux/codec/mod.rs | 11 +- src/mux/codec/mp2_channels_tests.rs | 26 +- src/mux/codec/mpeg2.rs | 248 ++++-- src/mux/codec/mpegaudio.rs | 8 +- src/mux/codec/pgs.rs | 118 ++- src/mux/codec/reorder.rs | 67 +- src/mux/codec/startcode.rs | 8 + src/mux/codec/truehd.rs | 76 +- src/mux/codec/vc1.rs | 156 ++-- src/mux/demux_sink.rs | 83 +- src/mux/demux_thread.rs | 34 +- src/mux/disc.rs | 468 +++++++--- src/mux/driver.rs | 53 +- src/mux/ebml.rs | 21 + src/mux/fmp4/mod.rs | 2 +- src/mux/fvi_sink.rs | 63 +- src/mux/header_gate.rs | 23 + src/mux/hevc/mod.rs | 85 +- src/mux/m2ts.rs | 6 + src/mux/meta.rs | 84 ++ src/mux/meta_sink.rs | 106 +++ src/mux/mkv.rs | 164 +++- src/mux/mkvstream.rs | 371 +++++++- src/mux/mod.rs | 5 +- src/mux/mp4/audio.rs | 57 +- src/mux/mp4/mod.rs | 72 +- src/mux/mp4/read.rs | 358 +++++++- src/mux/mpg/fit.rs | 26 + src/mux/mpg/mod.rs | 78 +- src/mux/mpg/pack.rs | 27 +- src/mux/mpg/pstd.rs | 267 +++++- src/mux/mpg/replay.rs | 2 +- src/mux/mpg/scan.rs | 109 ++- src/mux/mpg/tests.rs | 140 +++ src/mux/pipelined_stream.rs | 6 +- src/mux/ps.rs | 7 +- src/mux/resolve.rs | 352 +------- src/mux/select.rs | 34 +- src/mux/timeline.rs | 6 +- src/mux/ts.rs | 17 +- src/mux/tsmux.rs | 15 +- src/mux/videomap.rs | 30 +- src/pes.rs | 80 +- src/platform/fs_type/linux.rs | 46 +- src/platform/fs_type/macos.rs | 41 - src/platform/fs_type/mod.rs | 123 +-- src/platform/fs_type/other.rs | 12 - src/platform/fs_type/windows.rs | 27 - src/platform/mod.rs | 3 +- src/progress.rs | 4 +- src/scsi/fd_handoff.rs | 12 +- src/scsi/linux.rs | 133 +-- src/scsi/macos.rs | 197 ++++- src/scsi/macos_shim.c | 355 ++++---- src/scsi/mod.rs | 153 +++- src/scsi/windows.rs | 21 +- src/sector/bus_removal.rs | 59 +- src/sector/decrypting.rs | 63 +- src/sector/mod.rs | 3 +- src/sector/prefetched.rs | 102 ++- src/session.rs | 60 +- src/session/stop_tests.rs | 94 ++ src/spec/mpg.rs | 7 +- src/test_util.rs | 32 +- src/test_util_fake.rs | 25 +- src/test_util_tests.rs | 31 + src/testlog.rs | 3 +- src/udf.rs | 941 +++++++++++++------- src/whole_disc.rs | 37 +- src/whole_disc_tests.rs | 29 + tests/crypto_tests.rs | 187 +--- tests/disc_tests.rs | 4 +- tests/legacy_key_api_guard.rs | 21 +- tests/lint_stop.rs | 63 +- tests/macos_drive_tests.rs | 7 +- tests/macos_platform_tests.rs | 11 +- tests/pass_n_patch_fix.rs | 7 +- tests/scan_iso.rs | 7 +- tests/scsi_error_decoding.rs | 28 +- tests/scsi_recovery.rs | 3 +- tests/streams.rs | 50 +- tests/udf_tests.rs | 80 +- 196 files changed, 14014 insertions(+), 4397 deletions(-) create mode 100644 LICENSES/LicenseRef-ISO-dist10.txt create mode 100644 LICENSES/MIT.txt delete mode 100644 src/platform/fs_type/macos.rs delete mode 100644 src/platform/fs_type/other.rs delete mode 100644 src/platform/fs_type/windows.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4732e6b8..10843883 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,11 @@ version: 2 +# NOTE: the cargo updater cannot resolve the out-of-repo `freemkv-unlock` path +# dep on dev, so it opens no PRs; advisory coverage there is cargo-deny (ci.yml). +# # Dependency updates land on `dev`, never on `main`. # -# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot +# `main` here is a RELEASE POINTER that moves to each tag. A bot # commit on it would put work there that no tag contains, which is exactly the # state that aborted the 1.6.2 cascade at the last step -- so pointing # Dependabot at main would recreate that failure on a schedule. @@ -23,7 +26,7 @@ updates: - patch ignore: # The freemkv crates depend on each other by GIT TAG, re-pinned by - # release.sh as part of the release commit. Dependabot cannot see that + # release-orchestrate.yml as part of the release commit. Dependabot cannot see that # cascade, so a PR bumping one of these would fight the release process # and could pin a version whose tag does not exist yet. - dependency-name: freemkv-unlock @@ -48,16 +51,16 @@ updates: - patch ignore: # NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST - # release it installs, and the tag we pin is the toolchain CI is pinned - # to on purpose -- precommit.sh runs the same one locally so a lint that - # passes on a developer's newer default cannot pass CI by accident. + # release it installs, and CI selects the toolchain through the `toolchain:` + # input on the @master/@stable branch refs, so a lint that passes on a + # newer default cannot pass CI by accident. # - # Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0, + # Dependabot reads its tags as semver and proposed 1.97.0 -> 1.100.0, # a Rust version that does not exist. Every such PR 404s on toolchain # download across all eight repos, and they regenerate weekly -- eight # permanently-red PRs that promote.yml then has to special-case when it # decides whether dev is green. # # Bumping the toolchain is a deliberate, all-eight-repos change, made by - # hand together with precommit.sh. There is nothing here for a bot. + # hand. There is nothing here for a bot. - dependency-name: dtolnay/rust-toolchain diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2895180d..feb9bd63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,11 @@ jobs: # cannot drift. clippy and `consumers` deliberately run on `stable`. resolve-msrv: runs-on: ubuntu-latest + timeout-minutes: 5 outputs: v: ${{ steps.msrv.outputs.v }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - id: msrv shell: bash run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT" @@ -32,8 +33,9 @@ jobs: # whose entry isn't written yet is not blocked. changelog: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Require a CHANGELOG entry for the crate version on main shell: bash run: | @@ -55,8 +57,9 @@ jobs: lint: needs: resolve-msrv runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -64,12 +67,13 @@ jobs: checkout: freemkv-unlock - uses: dtolnay/rust-toolchain@stable with: + toolchain: stable components: clippy - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} components: rustfmt - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - run: cargo fmt --check @@ -80,8 +84,9 @@ jobs: test: needs: resolve-msrv runs-on: ubuntu-latest + timeout-minutes: 45 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -90,11 +95,14 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - run: cargo test --tests working-directory: libfreemkv + # `--tests` skips doctests; the keys compile_fail doctests enforce spec LK19. + - run: cargo test --doc + working-directory: libfreemkv # The memory orderings in `scsi::fd_handoff` (and `halt`) compile to the same instructions # on x86_64, so the WRONG ones pass every run; loom enumerates the executions @@ -105,10 +113,11 @@ jobs: loom: needs: resolve-msrv runs-on: ubuntu-latest + timeout-minutes: 30 env: RUSTFLAGS: --cfg loom steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -118,7 +127,7 @@ jobs: with: toolchain: ${{ needs.resolve-msrv.outputs.v }} components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - run: cargo test --lib --no-default-features --features scsi fd_handoff @@ -133,15 +142,18 @@ jobs: deny: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: checkout: freemkv-unlock - uses: dtolnay/rust-toolchain@stable - - uses: EmbarkStudios/cargo-deny-action@v2 + with: + toolchain: stable + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 with: command: check advisories bans licenses sources manifest-path: libfreemkv/Cargo.toml @@ -153,8 +165,9 @@ jobs: needs: resolve-msrv if: github.ref_name == 'qa' || github.ref_name == 'main' runs-on: macos-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -163,7 +176,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - run: cargo check @@ -173,8 +186,9 @@ jobs: needs: resolve-msrv if: github.ref_name == 'qa' || github.ref_name == 'main' runs-on: windows-latest + timeout-minutes: 45 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -183,7 +197,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv # Build the tests: forces full codegen of the Windows-only SPTI transport @@ -198,8 +212,9 @@ jobs: # engine fixture break the moment it landed, so it stays on the dev fast lane. consumers: runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: { path: libfreemkv } - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: @@ -227,7 +242,9 @@ jobs: # satisfies the highest dependent floor (the library's own lower MSRV, which # the `test` job verifies, cannot compile them). - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: | freemkv-keysources @@ -249,8 +266,9 @@ jobs: coverage: needs: resolve-msrv runs-on: ubuntu-latest + timeout-minutes: 45 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -260,13 +278,13 @@ jobs: with: toolchain: ${{ needs.resolve-msrv.outputs.v }} components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@72f835688cb6c15903b796f704b01e652a2b8835 # cargo-llvm-cov - run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info working-directory: libfreemkv - - uses: codecov/codecov-action@v7 + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: libfreemkv/lcov.info diff --git a/.github/workflows/ffmpeg-validation.yml b/.github/workflows/ffmpeg-validation.yml index 354852ee..31ae3da3 100644 --- a/.github/workflows/ffmpeg-validation.yml +++ b/.github/workflows/ffmpeg-validation.yml @@ -16,7 +16,7 @@ jobs: env: FREEMKV_FFMPEG_ARTIFACT_DIR: ${{ github.workspace }}/ffmpeg-artifacts steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -29,7 +29,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ steps.msrv.outputs.v }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - name: Install software encoders and decoders @@ -48,7 +48,7 @@ jobs: cargo test --release --lib ffmpeg_ -- --ignored --nocapture 2>&1 | tee "$FREEMKV_FFMPEG_ARTIFACT_DIR/tests.log" - name: Upload fixtures, probes, hashes and logs if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ffmpeg-validation-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ env.FREEMKV_FFMPEG_ARTIFACT_DIR }} diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 8a44df7b..4d99d890 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -33,10 +33,11 @@ jobs: # clippy (cross-lint) runs on stable instead. resolve-msrv: runs-on: ubuntu-latest + timeout-minutes: 5 outputs: v: ${{ steps.msrv.outputs.v }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - id: msrv shell: bash run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT" @@ -56,11 +57,14 @@ jobs: # # release.yml excludes v*-rc* so a candidate never publishes a release. rc-tag: + # A manual dispatch from another branch must not stamp a candidate. + if: github.ref_name == 'qa' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Stamp the next rc @@ -71,10 +75,16 @@ jobs: [ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; } # Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain # lexical sort gets that backwards from the tenth candidate on. - n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1) - tag="v$v-rc$(( ${n:-0} + 1 ))" - git tag "$tag" - git push origin "$tag" + # Retry on a lost race for the same ordinal: refetch tags, recompute. + for attempt in 1 2 3 4 5; do + git fetch --tags --force origin + n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1) + tag="v$v-rc$(( ${n:-0} + 1 ))" + git tag "$tag" + git push origin "$tag" && break + git tag -d "$tag" + [ "$attempt" -lt 5 ] || { echo "could not push an rc tag" >&2; exit 1; } + done echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY" # The debug suite runs on every dev push. Release is a DIFFERENT build: @@ -88,8 +98,9 @@ jobs: matrix: os: ['ubuntu-latest', 'macos-latest'] runs-on: ${{ matrix.os }} + timeout-minutes: 60 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -98,11 +109,13 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - run: cargo test --release --tests working-directory: libfreemkv + - run: cargo test --release --doc + working-directory: libfreemkv # Independent decoder/remux verification uses generated media and belongs # on qa, alongside the release suite. Also runnable manually on a branch. @@ -116,8 +129,9 @@ jobs: # target is how a lint that CI rejects reaches a push. cross-lint: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -127,12 +141,12 @@ jobs: # the MSRV pin. See ci.yml for why the lint toolchain need not be the MSRV. - uses: dtolnay/rust-toolchain@stable with: + toolchain: stable components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - - run: rustup target add x86_64-unknown-linux-gnu - - run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings + - run: cargo clippy --all-targets -- -D warnings working-directory: libfreemkv # Windows compiles the tests but does not run them, matching the policy @@ -142,8 +156,9 @@ jobs: windows-build: needs: resolve-msrv runs-on: windows-latest + timeout-minutes: 45 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: libfreemkv - uses: freemkv/.github/.github/actions/checkout-siblings@v1 @@ -152,7 +167,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: libfreemkv - run: cargo build --release --tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3eff4cc2..71a03afa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,13 +11,14 @@ on: - '!v*-rc*' permissions: - contents: write + contents: read jobs: verify: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Verify Cargo.toml version matches tag run: | CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')" @@ -28,23 +29,24 @@ jobs: echo "Version match: $CARGO_VER" # Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the - # publish/release jobs do NOT `needs:` this job. The tag decision was already + # release job does NOT `needs:` this job. The tag decision was already # gated by CI (ci.yml on dev, then a green qa.yml on the same commit). Binary consumers # (freemkv/autorip/bdemu) git-tag-pin libfreemkv and therefore start building - # the instant this tag exists — so this test job and the crates.io publish - # below must NOT sit on their critical path. + # the instant this tag exists — so this test job must NOT sit on their + # critical path. test: needs: verify runs-on: ubuntu-latest + timeout-minutes: 45 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - id: msrv shell: bash run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT" - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ steps.msrv.outputs.v }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Cargo.lock is now committed (issue #15), but --locked is still omitted: # the sibling path-patch layout resolves the freemkv crates as path # packages, which --locked can't satisfy. @@ -58,12 +60,15 @@ jobs: release: # Only needs `verify`; the GitHub Release can be cut as soon as the version - # check passes, in parallel with test + publish. + # check passes, in parallel with test. needs: verify runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Create GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: generate_release_notes: true diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fbe014d9..25036c27 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -14,14 +14,14 @@ jobs: security-events: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: ossf/scorecard-action@v2.4.4 + - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif publish_results: true - - uses: github/codeql-action/upload-sarif@v4 + - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 with: sarif_file: results.sarif diff --git a/Cargo.lock b/Cargo.lock index 1fdab1dc..1f319a7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -850,15 +850,3 @@ dependencies = [ "log", "simd-adler32", ] - -[[patch.unused]] -name = "freemkv-engine" -version = "1.7.7" - -[[patch.unused]] -name = "freemkv-i18n" -version = "1.7.7" - -[[patch.unused]] -name = "freemkv-keysources" -version = "1.7.7" diff --git a/Cargo.toml b/Cargo.toml index 212ff472..41533905 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ codegen-units = 1 # none of the ripping dependency tree. # `rip` — the full ripping tree (drive lifecycle, disc/UDF/MPLS/CLPI parsing, # AACS/CSS decrypt, PES/mux pipeline, keydb, freemkv-unlock seam). All -# heavy deps (serde, zip, rayon, roxmltree, rand, crossbeam, memchr, -# sha1, aes, base64, freemkv-unlock, serde_json) live here. +# heavy deps (serde, zip, rayon, roxmltree, crossbeam, memchr, +# sha1, sha2, aes, base64, socket2, rustix, freemkv-unlock, serde_json) live here. # `default = ["rip", "scsi"]` preserves today's full build byte-for-byte; # consumers wanting transport only use `default-features = false, features = # ["scsi"]`. `rip` implies `scsi` because the ripping tree reads through it. @@ -84,9 +84,8 @@ rustix = { version = "1", default-features = false, features = ["std", "event"], roxmltree = { version = "0.20", optional = true } # Trace-level instrumentation for the read/transport path (SgIoTransport::execute # and friends). Part of the `scsi` feature — the transport layer traces here. -# Permitted -# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip) -# wire a tracing subscriber and pipe events into the JSONL debug log. +# Debug/trace logging is the permitted string output. Consumers (autorip) wire a +# tracing subscriber and pipe events into the JSONL debug log. tracing = { version = "0.1", optional = true } # Bounded MPSC channel with kernel-wakeup send_timeout. Used by `io::pipeline` # so the halt-aware send/finish loops can BLOCK on consumer drain instead of diff --git a/LICENSES/LicenseRef-ISO-dist10.txt b/LICENSES/LicenseRef-ISO-dist10.txt new file mode 100644 index 00000000..167bda58 --- /dev/null +++ b/LICENSES/LicenseRef-ISO-dist10.txt @@ -0,0 +1,11 @@ +ISO/IEC 11172-5 reference software (dist10) -- table data + +src/mux/codec/testdata/dist10/alloc_0..3 are the Table 3-B.2a-d bit-allocation +tables of ISO/IEC 11172-3 Annex B, as shipped in the ISO/MPEG Audio Subgroup +Software Simulation Group's dist10 reference software (commit ea3c5ee of +joncampbell123/iso-dist10). They are used here only as test data to check the +Layer II parser. + +Copyright remains with ISO/IEC and the original authors of that software. No +separate licence text accompanies these files; they are not covered by this +project's MIT licence, and their terms are those of the dist10 distribution. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 00000000..9062b076 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Matthew Jackson & Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/REUSE.toml b/REUSE.toml index 6c5c27f6..b716e3e9 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -9,8 +9,9 @@ version = 1 # Rust sources, documentation, configuration, workflows, scripts, binary assets, # and the byte-exact `.golden` test fixtures — is covered here instead. # -# The whole repository is the project's own work under the MIT licence; there is -# no third-party vendored source here. +# The repository is the project's own work under the MIT licence, except the +# MPEG audio ISO reference tables under src/mux/codec/testdata/dist10, which are +# third-party test data and carry their own annotation at the end of this file. [[annotations]] path = [ @@ -35,7 +36,21 @@ path = [ "Makefile", "LICENSE", "build.rs", + "**/*.c", + "**/*.mp2", + "**/*.class", + "**/PROVENANCE", + "tests/*.txt", ] precedence = "aggregate" SPDX-FileCopyrightText = "2026 Matthew Jackson & Contributors" SPDX-License-Identifier = "MIT" + +# Table 3-B.2a-d allocation data copied from the ISO/IEC 11172-5 reference +# software (dist10, ISO/MPEG Audio Subgroup Software Simulation Group); this is +# not the project's work and is not MIT-licensed. +[[annotations]] +path = "src/mux/codec/testdata/dist10/alloc_*" +precedence = "aggregate" +SPDX-FileCopyrightText = "ISO/IEC JTC1/SC29/WG11 (MPEG) and the ISO/MPEG Audio Subgroup Software Simulation Group" +SPDX-License-Identifier = "LicenseRef-ISO-dist10" diff --git a/benches/sgio_read.rs b/benches/sgio_read.rs index adaf9c07..22e77866 100644 --- a/benches/sgio_read.rs +++ b/benches/sgio_read.rs @@ -55,6 +55,7 @@ fn main() { Ok(_) => { writer.write_all(&buf).unwrap(); ok += 1; + bytes += buf.len() as u64; } Err(e) => { fail += 1; @@ -65,7 +66,6 @@ fn main() { writer.write_all(&buf).unwrap(); } } - bytes += buf.len() as u64; if i % 50 == 0 && i > 0 { let elapsed = start.elapsed().as_secs_f64(); diff --git a/build.rs b/build.rs index 323f171a..bf9ddfa0 100644 --- a/build.rs +++ b/build.rs @@ -23,10 +23,24 @@ fn main() { &target_arch // x86_64 → x86_64 }; + // Match rustc's deployment target so the shim does not strongly link + // symbols newer than the app promises to run on (IOMainPort is 12+). + println!("cargo:rerun-if-env-changed=MACOSX_DEPLOYMENT_TARGET"); + let min_ver = std::env::var("MACOSX_DEPLOYMENT_TARGET").unwrap_or_else(|_| { + if target_arch == "aarch64" { + "11.0" + } else { + "10.12" + } + .to_string() + }); + let min_flag = format!("-mmacosx-version-min={min_ver}"); + let cc_status = std::process::Command::new("cc") .args([ "-arch", clang_arch, + &min_flag, "-c", "src/scsi/macos_shim.c", "-o", @@ -61,7 +75,7 @@ fn main() { /// Bake the git short hash into the build as `GIT_SUFFIX` so any muxed MKV or /// FVI index is traceable to the exact source revision (e.g. ` (g835cc99)`). -/// Empty when git or the repo is unavailable (e.g. a crates.io tarball build), +/// Empty when git or the repo is unavailable (e.g. a source-archive build), /// leaving just the package version. Always emitted so `env!("GIT_SUFFIX")` /// resolves on every target. fn emit_git_suffix() { diff --git a/examples/corpus_titledump.rs b/examples/corpus_titledump.rs index a5e5be78..bf6ac96c 100644 --- a/examples/corpus_titledump.rs +++ b/examples/corpus_titledump.rs @@ -43,9 +43,9 @@ fn dump_one(path: &Path) -> String { Ok(r) => r, Err(e) => { return format!( - "{{\"iso\":{},\"error\":\"open: {:?}\"}}", + "{{\"iso\":{},\"error\":{}}}", jstr(name), - format!("{e}") + jstr(&format!("open: {e}")) ); } }; @@ -55,9 +55,9 @@ fn dump_one(path: &Path) -> String { Ok(d) => d, Err(e) => { return format!( - "{{\"iso\":{},\"error\":\"scan: {}\"}}", + "{{\"iso\":{},\"error\":{}}}", jstr(name), - jstr(&format!("{e}")) + jstr(&format!("scan: {e}")) ); } }; diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 1927e31d..d8e21bf5 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -1089,15 +1089,14 @@ mod tests { // ── decrypt_bus: one key schedule per unit, not one per sector ───────── - // Regression pin: `decrypt_bus` used to expand a fresh AES-128 key per sector (3x/unit) for - // a loop-invariant key. + // Regression pin on production decrypt_bus_sectors: one key schedule per unit, not per sector. #[test] fn decrypt_bus_expands_the_read_data_key_once_per_unit() { use crate::aacs::crypto::KEY_EXPANSIONS; let mut unit = clear_unit(); let rdk = [0x4Eu8; 16]; KEY_EXPANSIONS.with(|c| c.set(0)); - decrypt_bus(&mut unit, &rdk); + decrypt_bus_sectors(&mut unit, &rdk); let n = KEY_EXPANSIONS.with(|c| c.get()); assert_eq!( n, 1, @@ -1455,24 +1454,26 @@ mod tests { // ── is_clean / ts_sync_count edge cases ──────────────────────────────── #[test] - fn ts_sync_destroyed_false_for_sub_unit_length() { - // The function guards on `len >= ALIGNED_UNIT_LEN` first; anything - // shorter is reported NOT scrambled (so the decrypt gate skips it) - // rather than indexing past the end. - assert!(crate::aacs::content::is_clean( - &[], + fn is_clean_ts_short_buffers_are_judged_on_content() { + // No length guard: a short buffer is judged over the packets it holds. + // All-zero (or empty) has no content packets, so it is clean. + for len in [0, ALIGNED_UNIT_LEN - 1] { + assert!(crate::aacs::content::is_clean( + &vec![0u8; len], + crate::disc::ContentFormat::BdTs + )); + } + // Non-zero payload without a sync byte in a 1-short buffer is NOT clean. + let mut noisy = vec![0u8; ALIGNED_UNIT_LEN - 1]; + noisy[192 + 4] = 0x00; + noisy[192 + 5] = 0x55; + assert!(!crate::aacs::content::is_clean( + &noisy, crate::disc::ContentFormat::BdTs )); + noisy[192 + 4] = TS_SYNC; assert!(crate::aacs::content::is_clean( - &vec![0u8; ALIGNED_UNIT_LEN - 1], - crate::disc::ContentFormat::BdTs - )); - // A scrambled-looking buffer that is one byte short is still "not - // scrambled" by the length guard. - let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1]; - almost[4] = 0x00; // no syncs - assert!(crate::aacs::content::is_clean( - &almost, + &noisy, crate::disc::ContentFormat::BdTs )); } diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index 5d8f0cda..858da03e 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -6,6 +6,9 @@ use super::inf::*; use super::mkb::*; use super::types::*; +/// `[C]` §3.2.5.1.4 Verify-Media-Key plaintext prefix. +pub(super) const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + /// Derive Media Key from MKB data using processing keys. /// /// A Processing Key is **terminal**: tried *directly* against the MKB @@ -98,7 +101,6 @@ fn validate_processing_key_with_cipher( // Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic. let dec_vd = aes_ecb_decrypt(&mk, mk_dv); - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; if dec_vd[..8] == VERIFY_MAGIC { return Some(mk); } @@ -130,7 +132,6 @@ pub(crate) fn validate_processing_key( // Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic. let dec_vd = aes_ecb_decrypt(&mk, mk_dv); - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; if dec_vd[..8] == VERIFY_MAGIC { return Some(mk); } @@ -371,7 +372,11 @@ pub(crate) fn resolve_dk_node( return Some(dk); } } - // Degenerate MKB (no gating bit): fall back to the node itself. + // Parsed tables but no node gates: the position is unwalkable, so don't bank it. + if tables.is_some() { + return None; + } + // No parseable tables: fall back to the node itself. Some(DeviceKey { key: *key, node: (uv & 0xFFFF) as u16, @@ -420,9 +425,7 @@ pub mod probe { /// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`. pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool { match super::mkb_find_mk_dv(mkb) { - Some(mk_dv) => { - aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF] - } + Some(mk_dv) => aes_ecb_decrypt(km, &mk_dv)[..8] == super::VERIFY_MAGIC, None => false, } } @@ -535,13 +538,23 @@ pub fn resolve_candidate( }; match candidate { - KeyCandidate::Uk(uk) => Some(ResolvedChain { - unit_keys: vec![(uk.idx, uk.key)], - vuk: None, - mk: None, - pk: None, - dk: None, - }), + KeyCandidate::Uk(uk) => { + // `uk.idx` is positional; surface the declared CPS-unit number like the other arms, + // falling back to the position if the file does not parse or lacks that slot. + let version = mkb_type(mkb) + .map(|t| t.generation()) + .unwrap_or(AacsVersion::V10); + let declared = parse_title_keys(unit_key_ro, version) + .and_then(|f| f.encrypted_keys.get(uk.idx as usize).map(|k| k.0)) + .unwrap_or(uk.idx); + Some(ResolvedChain { + unit_keys: vec![(declared, uk.key)], + vuk: None, + mk: None, + pk: None, + dk: None, + }) + } KeyCandidate::Vuk(v) => Some(ResolvedChain { unit_keys: boil(*v)?, vuk: Some(*v), @@ -595,14 +608,13 @@ mod resolve_candidate_tests { fn km_verifies_accepts_only_the_key_its_record_was_built_for() { use crate::aacs::mkb::mkb_find_mk_dv; - const MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; let km: [u8; 16] = [ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, ]; let mut plain = [0u8; 16]; - plain[..8].copy_from_slice(&MAGIC); + plain[..8].copy_from_slice(&VERIFY_MAGIC); plain[8..].copy_from_slice(&[0xA5; 8]); let mk_dv = aes_ecb_encrypt(&km, &plain); @@ -714,6 +726,15 @@ mod resolve_candidate_tests { assert!(r.vuk.is_none() && r.mk.is_none()); } + /// A UK candidate's positional idx is reported as the declared CPS-unit number. + #[test] + fn resolve_candidate_uk_reports_the_declared_cps_unit_number() { + let inf = synth_inf(&[[0x11u8; 16], [0x22u8; 16]]); + let uk = UnitKey::new(1, [0x9u8; 16]); + let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &inf, None).expect("terminal"); + assert_eq!(r.unit_keys, vec![(2, uk.key)]); + } + /// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops. #[test] fn resolve_candidate_mk_requires_vid() { @@ -1043,9 +1064,6 @@ mod position_recovery_tests { use super::*; use crate::aacs::crypto::aes_ecb_encrypt; - /// `[C]` §3.2.5.1.4 Verify-Media-Key plaintext prefix. - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; - /// An MKB record: 1-byte type + BE24 total length (header included) + body. fn rec(t: u8, body: &[u8]) -> Vec { let total = 4 + body.len(); @@ -1212,6 +1230,17 @@ mod position_recovery_tests { ); } + // Parsed tables where no node gates (key not in this MKB's slot): resolving must + // fail rather than return a key that can never derive. + #[test] + fn resolve_dk_node_rejects_an_unwalkable_position() { + let p = plant_mkb(); + assert!( + resolve_dk_node(&p.mkb, &[0x11; 16], p.uv, p.u_mask_shift).is_none(), + "no node gates for a key the MKB does not open" + ); + } + // `u_mask_shift` is a disc/keydb-controlled u8; a value >= 32 would drive // `1u32 << b` past a u32's width and panic (debug) / wrap (release). The `.min(32)` // guard must bound the search so a hostile disc can never trip the shift. @@ -1599,6 +1628,21 @@ mod position_recovery_tests { ); } + // A keydb device key with u_mask_shift >= 32 must be skipped at the DK-side guard; + // an unguarded `0xFFFF_FFFF << 200` panics in debug on keydb-supplied input. + #[test] + fn dk_walk_skips_a_device_key_with_u_mask_shift_past_u32() { + let (dkey, _mk, _pk, cv, mk_dv) = four_level_parts(); + let mkb = build_mkb(&[(U_MASK_SHIFT, UV_SLOT4)], &cv, &mk_dv); + let dk = DeviceKey { + key: dkey, + node: 0x0101, + uv: UV_ANC4, + u_mask_shift: 200, + }; + assert_eq!(derive_media_key_and_pk_from_dk(&mkb, &[dk]), None); + } + // ── L104: cvalue-loop cipher hoist — equivalence, call-count, and spec quote ────── /// Test-only ORACLE: `try_pk_against_tables` as it read before L104 — rebuilds an diff --git a/src/aacs/index_select.rs b/src/aacs/index_select.rs index 03623dea..11d8bfe6 100644 --- a/src/aacs/index_select.rs +++ b/src/aacs/index_select.rs @@ -4,7 +4,7 @@ //! tags each segment with an index (see [`super::segment`]). Segments matching our //! index are kept, others dropped; everything outside a segment is ordinary (index-0) //! content. This module owns that classification only — no I/O, no keys, no cipher. -//! The decrypt pipeline consumes the [`UnitDisposition`] it returns. +//! Standalone helper: the live FMTS path is `keys::fmts` + `mux::resolve`, not this module. use super::segment::{Segment, segment_for_unit}; use super::types::UnitKey; diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs index 8b56ea51..9d47d49b 100644 --- a/src/aacs/inf.rs +++ b/src/aacs/inf.rs @@ -118,8 +118,8 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option data.len() { @@ -275,15 +275,15 @@ const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83; /// MKB pack buffer size. const MKB_PACK_SIZE: usize = 32772; -/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). -/// Returns the concatenated MKB data from all packs. A pack declaring more than -/// the response buffer holds is a drive fault (`AacsKeyRead`), never a holed MKB. -pub fn read_mkb_from_drive( +/// Read one MKB pack: returns (declared pack count, payload). A short transfer or a +/// payload past the buffer is a drive fault, never a holed MKB. +fn read_mkb_pack( session: &mut dyn crate::scsi::ScsiTransport, -) -> crate::error::Result> { + pack: u32, +) -> crate::error::Result<(usize, Option>)> { use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; - let cdb = [ + let mut cdb = [ SCSI_READ_DISC_STRUCTURE, 0x01, 0x00, @@ -297,54 +297,48 @@ pub fn read_mkb_from_drive( 0x00, 0x00, ]; - let mut buf = vec![0u8; 32772]; - session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?; + // Pack number goes in address field + cdb[2..6].copy_from_slice(&pack.to_be_bytes()); + + let mut buf = vec![0u8; MKB_PACK_SIZE]; + // Transport errors propagate: swallowing one would return a truncated MKB as Ok. + let r = session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?; let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; + let num_packs = buf[3] as usize; if data_len < 2 { - return Ok(Vec::new()); + return Ok((num_packs, None)); } let len = data_len - 2; - let num_packs = buf[3] as usize; + if len > MKB_PACK_SIZE - 4 || r.bytes_transferred < 4 + len { + return Err(crate::error::Error::AacsKeyRead); + } + Ok((num_packs, Some(buf[4..4 + len].to_vec()))) +} - if len > 32768 { +/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). +/// Returns the concatenated MKB data from all packs. A short or over-long pack is a +/// drive fault (`AacsKeyRead`), never a holed MKB; a header-only pack in a multi-pack MKB is `AacsKeyRead`; a single header-only pack is an empty MKB. +pub fn read_mkb_from_drive( + session: &mut dyn crate::scsi::ScsiTransport, +) -> crate::error::Result> { + let (num_packs, first) = read_mkb_pack(session, 0)?; + let Some(mut mkb) = first else { + return Ok(Vec::new()); + }; + // A header-only pack in a multi-pack MKB is a hole, not an empty MKB. + if num_packs > 1 && mkb.is_empty() { return Err(crate::error::Error::AacsKeyRead); } - let mut mkb = Vec::with_capacity(32768 * num_packs.max(1)); - mkb.extend_from_slice(&buf[4..4 + len]); // Read remaining packs for pack in 1..num_packs { - let mut cdb = [ - SCSI_READ_DISC_STRUCTURE, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - MKB_DISC_STRUCTURE_FORMAT, - (MKB_PACK_SIZE >> 8) as u8, - (MKB_PACK_SIZE & 0xFF) as u8, - 0x00, - 0x00, - ]; - // Pack number goes in address field - cdb[2] = ((pack >> 24) & 0xFF) as u8; - cdb[3] = ((pack >> 16) & 0xFF) as u8; - cdb[4] = ((pack >> 8) & 0xFF) as u8; - cdb[5] = (pack & 0xFF) as u8; - - let mut buf = vec![0u8; 32772]; - // A transport error on a non-first pack must PROPAGATE: swallowing it - // (the prior `.is_ok()`) silently truncated the MKB and returned the - // partial data as Ok, corrupting the root of the whole AACS ladder. - session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?; - let len = (u16::from_be_bytes([buf[0], buf[1]]) as usize).saturating_sub(2); - if len > 32768 { + let (_, body) = read_mkb_pack(session, pack as u32)?; + let body = body.ok_or(crate::error::Error::AacsKeyRead)?; + if body.is_empty() { return Err(crate::error::Error::AacsKeyRead); } - mkb.extend_from_slice(&buf[4..4 + len]); + mkb.extend_from_slice(&body); } Ok(mkb) @@ -669,22 +663,34 @@ mod read_mkb_tests { assert!(mkb == expected, "both maximal packs' bytes must be intact"); } - /// A pack that declares only the 2-byte header and NO payload contributes - /// nothing, and must not push a phantom byte into the MKB — an off-by-one - /// at the zero-length boundary corrupts every following pack's alignment. + /// A header-only pack inside a multi-pack MKB is a hole: fail rather than return a + /// silently shortened MKB. A single-pack header-only response stays an empty MKB. #[test] fn read_mkb_from_drive_zero_length_pack_contributes_nothing() { let mut drive = MkbDrive { packs: vec![Vec::new(), vec![0xABu8; 32]], cdbs: Vec::new(), }; - let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers"); + assert!(matches!( + read_mkb_from_drive(&mut drive), + Err(crate::error::Error::AacsKeyRead) + )); + let mut drive = MkbDrive { + packs: vec![vec![0xABu8; 32], Vec::new()], + cdbs: Vec::new(), + }; + assert!(matches!( + read_mkb_from_drive(&mut drive), + Err(crate::error::Error::AacsKeyRead) + )); + let mut drive = MkbDrive { + packs: vec![Vec::new()], + cdbs: Vec::new(), + }; assert_eq!( - mkb.len(), - 32, - "an empty pack adds no bytes; only pack 1's payload is present" + read_mkb_from_drive(&mut drive).expect("single pack"), + Vec::::new() ); - assert!(mkb == vec![0xABu8; 32], "and the bytes are pack 1's"); } // A drive-declared length past the 32772-byte buffer is a drive fault: the MKB @@ -754,6 +760,46 @@ mod read_mkb_tests { assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code()); } + /// A drive declaring a payload but transferring fewer bytes (zero-filled tail) is a + /// fault; so is a header-only pack after a real first pack (a hole). + #[test] + fn read_mkb_from_drive_rejects_short_transfer_and_holed_pack() { + struct Scripted { + declared: [u16; 2], + xfer: usize, + } + impl ScsiTransport for Scripted { + fn execute( + &mut self, + cdb: &[u8], + _direction: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::error::Result { + let pack = cdb[5] as usize; + data[0..2].copy_from_slice(&self.declared[pack].to_be_bytes()); + data[3] = 2; + Ok(ScsiResult { + status: 0, + bytes_transferred: if pack == 0 { self.xfer } else { 4 }, + sense: [0u8; 32], + }) + } + } + let short = read_mkb_from_drive(&mut Scripted { + declared: [102, 2], + xfer: 4, + }) + .expect_err("short transfer"); + assert_eq!(short.code(), crate::error::Error::AacsKeyRead.code()); + let hole = read_mkb_from_drive(&mut Scripted { + declared: [102, 0], + xfer: 104, + }) + .expect_err("holed pack"); + assert_eq!(hole.code(), crate::error::Error::AacsKeyRead.code()); + } + /// A single-pack disc still yields that pack's bytes — the common case, and /// the one where a body returning an empty vector looks most plausible. #[test] diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 8dee99ce..d982f827 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -9,23 +9,24 @@ pub mod content; pub mod crypto; pub mod derive; pub mod host_certs; +// No production caller; not the live FMTS path. +#[doc(hidden)] pub mod index_select; pub mod inf; pub mod mkb; pub mod provider; pub mod resolve; pub mod segment; +// No production caller; not the live FMTS path. +#[doc(hidden)] pub mod segment_key; pub mod trace; pub mod types; pub mod variant; -/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory -/// discovery (never hardcoded — see [`find_hddvd_aacs_dir`], [`role_paths`]). -/// -/// Each [`AacsRole`] resolves to an ordered candidate list, walked by -/// [`read_first`] to the first that reads, so every reader shares one source -/// of truth for disc_hash / MKB / VID. +// On-disc UDF paths to the AACS key-input files; each AacsRole resolves to an ordered +// candidate list walked by read_first (see role_paths, find_hddvd_aacs_dir). +/// Primary Unit Key file path. pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf"; pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf"; pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf"; diff --git a/src/aacs/provider.rs b/src/aacs/provider.rs index 46f53d60..cb7fd337 100644 --- a/src/aacs/provider.rs +++ b/src/aacs/provider.rs @@ -275,6 +275,26 @@ mod tests { assert!(nodes.contains(&5) && nodes.contains(&6)); } + #[test] + fn providers_device_keys_dedup_keeps_distinct_uv_and_mask_shift() { + // Same key and node, differing only in uv or u_mask_shift, are distinct positions. + let base = dk(0x22, 7); + let uv = DeviceKey { + uv: 2, + ..dk(0x22, 7) + }; + let shift = DeviceKey { + u_mask_shift: 3, + ..dk(0x22, 7) + }; + let a = Fixed { + dks: vec![base.clone(), uv, shift, base], + ..Default::default() + }; + let arr: &[&dyn KeyProvider] = &[&a]; + assert_eq!(Providers(arr).device_keys().len(), 3); + } + // ── Disc-keyed lookups: array-order short-circuit ────────────────────── #[test] diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index 5469a00b..8db99ea3 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -207,25 +207,23 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option { if let Some(mkb) = ctx.mkb { let recs = super::mkb::walk_mkb(mkb); let all_dks = providers.device_keys(); - if let Some(pkm) = super::variant::walk_processing_key(&recs, &all_dks) { - match super::variant::derive_media_key_variant(&recs, &pkm.kp) { - Ok(km) => { - let kvu = derive_vuk(&km, ctx.volume_id); - tracing::debug!( - target: "freemkv::disc", - phase = "resolve_keys_v21_path1_hit", - "Variant chain produced Km + Kvu" - ); - return Some(build(Some(kvu), derive_uks(&kvu), 1)); - } - Err(e) => { - tracing::debug!( - target: "freemkv::disc", - phase = "resolve_keys_v21_path1_miss", - error_code = %e, - "Variant chain failed" - ); - } + match super::variant::derive_media_key_from_device_keys(&recs, &all_dks) { + Ok(km) => { + let kvu = derive_vuk(&km, ctx.volume_id); + tracing::debug!( + target: "freemkv::disc", + phase = "resolve_keys_v21_path1_hit", + "Variant chain produced Km + Kvu" + ); + return Some(build(Some(kvu), derive_uks(&kvu), 1)); + } + Err(e) => { + tracing::debug!( + target: "freemkv::disc", + phase = "resolve_keys_v21_path1_miss", + error_code = %e, + "Variant chain failed" + ); } } } @@ -358,9 +356,7 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt // UNIQUE pass via km_verifies is this disc's Km); mk_dv is hoisted out. let mks = providers.media_keys(); let chosen_mk = mkb_find_mk_dv(mkb).and_then(|mk_dv| { - unique_verifying_mk(&mks, |mk| { - aes_ecb_decrypt(mk, &mk_dv)[..8] == MK_VERIFY_MAGIC - }) + unique_verifying_mk(&mks, |mk| aes_ecb_decrypt(mk, &mk_dv)[..8] == VERIFY_MAGIC) }); if let Some(mk) = chosen_mk { let vuk = derive_vuk(&mk, ctx.volume_id); @@ -419,11 +415,6 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt None } -/// First 8 bytes of the plaintext behind an MKB Verify Media Key record — the -/// AACS "this is the right Km" sentinel (`0123456789ABCDEF`). A candidate MK -/// verifies when AES-128-ECB-D(mk, mk_dv) starts with it. -const MK_VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; - // Path 2.5's MK-pool selection rule: a Media Key must be the EXACTLY ONE distinct verifying // candidate (dupes across providers are one candidate). fn unique_verifying_mk(mks: &[[u8; 16]], verifies: impl Fn(&[u8; 16]) -> bool) -> Option<[u8; 16]> { @@ -1079,6 +1070,98 @@ mod tests { ); } + // Synthetic single-slot MKB keyed by `pk`: cvalue = AES-E(pk, mk ^ uv), verify record + // for `mk`. Returns (mkb, mk). + fn planted_pk_mkb(pk: &[u8; 16]) -> (Vec, [u8; 16]) { + let mk = [0xA5u8; 16]; + let uv: u32 = 0x0000_0400; + let mut raw = mk; + for (a, b) in raw[12..16].iter_mut().zip(uv.to_be_bytes()) { + *a ^= b; + } + let cv = aes_ecb_encrypt(pk, &raw); + let mut vd = [0x5Au8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mut sub = vec![12u8]; + sub.extend_from_slice(&uv.to_be_bytes()); + let mut mkb = mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]); + mkb.extend_from_slice(&mkb_record(0x86, &aes_ecb_encrypt(&mk, &vd))); + mkb.extend_from_slice(&mkb_record(0x04, &sub)); + mkb.extend_from_slice(&mkb_record(0x05, &cv)); + (mkb, mk) + } + + fn resolve_with(keydb: &SuppliedKey, mkb: &[u8], vid: &[u8; 16]) -> Option { + let uk_ro = minimal_unit_key_ro(); + let providers: &[&dyn super::super::provider::KeyProvider] = &[keydb]; + resolve_keys_v1(&ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: vid, + providers, + mkb: Some(mkb), + }) + } + + #[test] + fn resolve_keys_path2_processing_key_derives_vuk() { + let pk = [0x3Cu8; 16]; + let (mkb, mk) = planted_pk_mkb(&pk); + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: vec![pk], + media_keys: Vec::new(), + disc_entry: None, + }; + let vid = [0x22u8; 16]; + let r = resolve_with(&keydb, &mkb, &vid).expect("path 2 resolves"); + assert_eq!(r.key_source, 2); + assert_eq!(r.vuk, Some(derive_vuk(&mk, &vid))); + } + + #[test] + fn resolve_keys_path1_device_key_derives_vuk() { + let dkey = [0x1Du8; 16]; + let (mkb, mk) = planted_pk_mkb(&aesg3(&dkey, 1)); + let dk = super::super::derive::recover_dk_position(&mkb, &dkey) + .expect("planted key applies to the MKB"); + let keydb = SuppliedKey { + device_keys: vec![dk], + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: None, + }; + let vid = [0x22u8; 16]; + let r = resolve_with(&keydb, &mkb, &vid).expect("path 1 resolves"); + assert_eq!(r.key_source, 1); + assert_eq!(r.vuk, Some(derive_vuk(&mk, &vid))); + } + + // With a real MKB and derivation material present, a zero VID must still skip paths 1/2/2.5; + // only the has_vid gate stops them (the MKB itself would resolve). + #[test] + fn resolve_keys_zero_vid_gate_blocks_mkb_paths_even_with_a_resolvable_mkb() { + let pk = [0x3Cu8; 16]; + let (mkb, mk) = planted_pk_mkb(&pk); + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: vec![pk], + media_keys: vec![mk], + disc_entry: None, + }; + assert!(resolve_with(&keydb, &mkb, &[0u8; 16]).is_none()); + assert!(resolve_with(&keydb, &mkb, &[0x22u8; 16]).is_some()); + } + + // Known-answer vector: FIPS 180 SHA-1("abc"). + #[test] + fn disc_hash_matches_the_sha1_known_answer() { + assert_eq!( + disc_hash_hex(&disc_hash(b"abc")), + "0xA9993E364706816ABA3E25717850C26C9CD0D89D" + ); + } + #[test] fn resolve_keys_path4_still_runs_when_vid_is_zero() { // Path 4 (disc-hash → VUK) doesn't need VID; confirm the short-circuit diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index f32f8485..c8f95093 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -16,7 +16,7 @@ /// Fixed size of one `IndividualSegment.tbl` record. pub const SEGMENT_RECORD_LEN: usize = 16; /// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header). -pub const SOURCE_PACKET_LEN: u64 = 192; +pub const SOURCE_PACKET_LEN: u64 = crate::consts::BD_SOURCE_PACKET_BYTES as u64; /// One forensic segment: the inclusive source-packet range it occupies in the /// FMTS clip. @@ -73,7 +73,7 @@ pub const PACKETS_PER_UNIT: u32 = /// byte and this offset lines up with the source-packet grid the segment map /// uses. pub fn lba_byte_offset(lba: u32) -> u64 { - lba as u64 * 2048 + lba as u64 * crate::consts::SECTOR_BYTES_U64 } /// The forensic segment an AACS aligned unit belongs to, if any, given the @@ -149,8 +149,9 @@ pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Opti /// segment are left for the map's default (the Unit Key). A segment straddling a UDF extent /// boundary is skipped rather than emitting a wrong span. /// -/// Feeds `AacsKeyMap::from_ranges` -/// with the Unit-Key index as the default. +/// Not used by the live FMTS path (`keys::fmts` + `mux::resolve`); kept as a public +/// helper for callers that build their own key map from the segment table. +#[doc(hidden)] pub fn fmts_key_ranges( segments: &[Segment], extents: &[crate::disc::Extent], diff --git a/src/aacs/segment_key.rs b/src/aacs/segment_key.rs index 8f0c8397..4531ecde 100644 --- a/src/aacs/segment_key.rs +++ b/src/aacs/segment_key.rs @@ -129,6 +129,7 @@ mod tests { assert_eq!(t.record_size(), 536); let rec = t.record(0x1234).expect("record"); assert_eq!(rec.len(), 536); + assert_eq!(rec[8], 0x34, "record 0x1234 carries its own fixture tag"); assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]); assert_eq!(t.record_payload(0x1234).unwrap().len(), 528); } diff --git a/src/aacs/types.rs b/src/aacs/types.rs index 9facd5ed..588dc1cf 100644 --- a/src/aacs/types.rs +++ b/src/aacs/types.rs @@ -17,9 +17,9 @@ pub struct DeviceKey { /// Host certificate + private key for AACS SCSI authentication. #[derive(Clone)] pub struct HostCert { - /// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes. + /// AACS 1.0 host private key, 20 bytes (AACS 2.0 uses `private_key_v2`). pub private_key: [u8; 20], - /// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes. + /// AACS 1.0 host certificate, 92 bytes (AACS 2.0 uses `certificate_v2`). pub certificate: Vec, /// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only. pub private_key_v2: Option<[u8; 32]>, diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index adc1977e..f0433659 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -78,7 +78,7 @@ pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> { // ── Subset-difference walk that exposes (Kp, uv) ────────────────────────── // Shared with the classical walk in super::derive to keep the SD tree byte-identical. -use super::derive::{calc_pk_from_dk, calc_v_mask}; +use super::derive::{VERIFY_MAGIC, calc_pk_from_dk, calc_v_mask}; /// Outcome of a subset-difference walk against an MKB. Carries the /// processing key and the matching `uv` slot — both needed as inputs @@ -109,7 +109,7 @@ impl std::fmt::Debug for ProcessingKeyMatch { } } -fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { +fn records_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { let r = records.iter().find(|r| { (r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2) && r.body.len() >= 16 @@ -119,6 +119,36 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { Some(out) } +/// Try each device key in turn (walk -> Kp -> Km) and return the first Media Key that +/// verifies. On a variant MKB the walk cannot verify a Kp, so a covering-but-wrong key +/// must not stop the search. Errs with the first correction/structural error, else the last +/// plain miss, or `ProcessingKeyUnavailable`. +pub(crate) fn derive_media_key_from_device_keys( + records: &[MkbRecord], + device_keys: &[DeviceKey], +) -> Result<[u8; 16], MediaKeyVariantError> { + let mut last = MediaKeyVariantError::ProcessingKeyUnavailable; + let mut notable: Option = None; + for dk in device_keys { + let Some(pkm) = walk_processing_key(records, std::slice::from_ref(dk)) else { + continue; + }; + match derive_media_key_variant(records, &pkm.kp) { + Ok(km) => return Ok(km), + Err( + e @ (MediaKeyVariantError::SoftCorrectionRequired + | MediaKeyVariantError::OnlineChallengeRequired + | MediaKeyVariantError::MkbIncomplete + | MediaKeyVariantError::VariantsTableUnavailable), + ) => { + notable.get_or_insert(e); + } + Err(e) => last = e, + } + } + Err(notable.unwrap_or(last)) +} + /// Walk an MKB and return the first `(Kp, uv, cvalue)` that /// `device_keys` covers. Returns `None` if no DK walks any uv. /// @@ -129,7 +159,7 @@ pub fn walk_processing_key( records: &[MkbRecord], device_keys: &[DeviceKey], ) -> Option { - let mk_dv = mkb_find_mk_dv(records)?; + let mk_dv = records_find_mk_dv(records)?; let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?; // Real variant MKBs carry per-uv cvalues in record `0x0c` (46,101x16, one // per `0x04` slot); fall back to `0x05` (never the `0x07` SD index). @@ -193,7 +223,6 @@ pub fn walk_processing_key( km_candidate[12 + i] ^= uv_bytes[i]; } let dec_vd = aes_ecb_decrypt(&km_candidate, &mk_dv); - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; // On classical MKBs this magic must match. On variant MKBs // it won't — `km_candidate` is really Kmp, so the magic // check is moot; the chain enforces semantics downstream. @@ -248,19 +277,26 @@ pub enum MediaKeyVariantError { MediaKeyVerifyFailed, } +impl MediaKeyVariantError { + /// The stable numeric error code (the `E71xx` family). + pub fn code(&self) -> u16 { + use crate::error::*; + match self { + MediaKeyVariantError::NotVariantMkb => E_MKB_VARIANT_NOT_VARIANT, + MediaKeyVariantError::MkbIncomplete => E_MKB_VARIANT_INCOMPLETE, + MediaKeyVariantError::ProcessingKeyUnavailable => E_MKB_VARIANT_PK_UNAVAILABLE, + MediaKeyVariantError::SoftCorrectionRequired => E_MKB_VARIANT_SOFT_CORRECTION, + MediaKeyVariantError::OnlineChallengeRequired => E_MKB_VARIANT_ONLINE_CHALLENGE, + MediaKeyVariantError::VariantsTableUnavailable => E_MKB_VARIANT_TABLE_UNAVAILABLE, + MediaKeyVariantError::VkdIndexOutOfRange => E_MKB_VARIANT_VKD_RANGE, + MediaKeyVariantError::MediaKeyVerifyFailed => E_MKB_VARIANT_VERIFY_FAILED, + } + } +} + impl std::fmt::Display for MediaKeyVariantError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let code: u16 = match self { - MediaKeyVariantError::NotVariantMkb => 7100, - MediaKeyVariantError::MkbIncomplete => 7101, - MediaKeyVariantError::ProcessingKeyUnavailable => 7102, - MediaKeyVariantError::SoftCorrectionRequired => 7103, - MediaKeyVariantError::OnlineChallengeRequired => 7104, - MediaKeyVariantError::VariantsTableUnavailable => 7106, - MediaKeyVariantError::VkdIndexOutOfRange => 7107, - MediaKeyVariantError::MediaKeyVerifyFailed => 7108, - }; - write!(f, "E{code}") + write!(f, "E{}", self.code()) } } @@ -349,16 +385,29 @@ fn variant_km_for_slot( let mut c_block = [0u8; 16]; c_block.copy_from_slice(c_slice); - // Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes). - let mut kmp = aes_ecb_decrypt(kp, &c_block); + let variants_uv = variants_for_uv(m.records, slot_index); + km_from_slot_inputs(kp, &c_block, uv, variants_uv, m.kvn, m.vkd_table, &m.mk_dv) +} + +/// Shared Kp -> Km chain (Kmp, correction bits, Kpnew, VKD, Km, Verify-Media-Key gate) for one +/// slot's explicit inputs. `variants_uv` is `VARIANTS[uv]`; `None` means the table is short. +fn km_from_slot_inputs( + kp: &[u8; 16], + c_block: &[u8; 16], + uv: u32, + variants_uv: Option, + kvn: u16, + vkd_table: &[u8], + mk_dv: &[u8; 16], +) -> Result<[u8; 16], MediaKeyVariantError> { + // Kmp = AES-128D(Kp, C) XOR uv (uv into the low 4 bytes). + let mut kmp = aes_ecb_decrypt(kp, c_block); let uv_bytes = uv.to_be_bytes(); for i in 0..4 { kmp[12 + i] ^= uv_bytes[i]; } - // Condition bits on Kmp[15] select the correction mode. Bit 0x02 (SoftKCD) - // and 0x04 (online challenge) need out-of-band data we don't model; the - // default path (neither bit set) uses the fixed KCD constant. + // Bits 0x02 (SoftKCD) / 0x04 (online challenge) need out-of-band data we don't model. if kmp[15] & 0b0000_0010 != 0 { return Err(MediaKeyVariantError::SoftCorrectionRequired); } @@ -366,36 +415,29 @@ fn variant_km_for_slot( return Err(MediaKeyVariantError::OnlineChallengeRequired); } - // Step: Kpnew = Kmp XOR KCD. + // Kpnew = Kmp XOR KCD. let mut kpnew = [0u8; 16]; for i in 0..16 { kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i]; } - // Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx]. Kvn = AES-G(Kp, Nonce) & - // 0xFFFF is loop-invariant for this Kp (L103): hoisted into `m.kvn` by the caller instead - // of being recomputed for each of the ~46k slots this function is tried against. - let v_for_uv = variants_for_uv(m.records, slot_index) - .ok_or(MediaKeyVariantError::VariantsTableUnavailable)?; - let vkd_idx = m.kvn ^ v_for_uv; - let off = (vkd_idx as usize) * 16; - if off + 16 > m.vkd_table.len() { + // VKD_idx = Kvn XOR VARIANTS[uv]; Kvn is loop-invariant for a Kp, hoisted by the caller. + let variants_uv = variants_uv.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?; + let off = ((kvn ^ variants_uv) as usize) * 16; + if off + 16 > vkd_table.len() { return Err(MediaKeyVariantError::VkdIndexOutOfRange); } let mut vkd = [0u8; 16]; - vkd.copy_from_slice(&m.vkd_table[off..off + 16]); + vkd.copy_from_slice(&vkd_table[off..off + 16]); - // Step: Km = AES-128D(Kpnew, VKD) XOR uv. + // Km = AES-128D(Kpnew, VKD) XOR uv. let mut km = aes_ecb_decrypt(&kpnew, &vkd); for i in 0..4 { km[12 + i] ^= uv_bytes[i]; } - // Gate: the derived Media Key MUST reproduce the MKB's Verify-Media-Key magic - // (the per-match magic in `walk_processing_key` only saw the Precursor). This - // is the authoritative check — no unverified key is ever returned. - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; - if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC { + // Authoritative gate: Km must reproduce the MKB's Verify-Media-Key magic. + if aes_ecb_decrypt(&km, mk_dv)[..8] != VERIFY_MAGIC { return Err(MediaKeyVariantError::MediaKeyVerifyFailed); } Ok(km) @@ -423,7 +465,7 @@ pub fn derive_media_key_variant( let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA) .or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA)) .ok_or(MediaKeyVariantError::MkbIncomplete)?; - let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; + let mk_dv = records_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; // Kvn = AES-G(Kp, Nonce) & 0xFFFF depends only on `pk` and the MKB's Nonce — neither // varies per slot — so compute it ONCE here rather than once per slot in @@ -480,47 +522,11 @@ pub fn media_key_variant_from_kp( ) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> { let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; - let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; + let mk_dv = records_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; - // Kmp = AES-128D(Kp, C) XOR uv. - let mut kmp = aes_ecb_decrypt(kp, c_block); - let uv_bytes = uv.to_be_bytes(); - for i in 0..4 { - kmp[12 + i] ^= uv_bytes[i]; - } - if kmp[15] & 0b0000_0010 != 0 { - return Err(MediaKeyVariantError::SoftCorrectionRequired); - } - if kmp[15] & 0b0000_0100 != 0 { - return Err(MediaKeyVariantError::OnlineChallengeRequired); - } - - // Kpnew = Kmp XOR KCD. - let mut kpnew = [0u8; 16]; - for i in 0..16 { - kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i]; - } - - // Kvn = AES-G(Kp, Nonce) & 0xFFFF; VKD_idx = Kvn XOR VARIANTS[uv]. let kvn_block = aes_g(kp, &nonce); let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); - let vkd_idx = kvn ^ variants_uv; - let off = (vkd_idx as usize) * 16; - if off + 16 > vkd_table.len() { - return Err(MediaKeyVariantError::VkdIndexOutOfRange); - } - let mut vkd = [0u8; 16]; - vkd.copy_from_slice(&vkd_table[off..off + 16]); - - // Km = AES-128D(Kpnew, VKD) XOR uv, then the authoritative Verify-Media-Key gate. - let mut km = aes_ecb_decrypt(&kpnew, &vkd); - for i in 0..4 { - km[12 + i] ^= uv_bytes[i]; - } - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; - if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC { - return Err(MediaKeyVariantError::MediaKeyVerifyFailed); - } + let km = km_from_slot_inputs(kp, c_block, uv, Some(variants_uv), kvn, vkd_table, &mk_dv)?; // Kvu = AES-G(Km, VID). let kvu = aes_g(&km, vid); @@ -1033,6 +1039,10 @@ mod tests { ]; let codes: HashSet = cases.iter().map(|e| e.to_string()).collect(); assert_eq!(codes.len(), cases.len(), "all error codes must be unique"); + // Registered in error.rs (whose own test guards cross-module uniqueness). + let nums: Vec = cases.iter().map(|e| e.code()).collect(); + assert_eq!(nums, [7100, 7101, 7102, 7103, 7104, 7106, 7107, 7108]); + assert!(!nums.contains(&7105), "7105 is a reserved gap"); } /// A `c_block` planting `Kmp[15]` bit `0x02` must surface @@ -1099,7 +1109,6 @@ mod tests { fn plant_variant_mkb() -> PlantedVariant { use crate::aacs::crypto::{aes_ecb_encrypt, aes_g}; - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; const UV: u32 = 2; const U_MASK_SHIFT: u8 = 3; @@ -1252,7 +1261,6 @@ mod tests { for i in 0..4 { km[12 + i] ^= uv_bytes[i]; } - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC { return Err(MediaKeyVariantError::MediaKeyVerifyFailed); } @@ -1270,7 +1278,7 @@ mod tests { let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA) .or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA)) .expect("cvalues present"); - let mk_dv = mkb_find_mk_dv(records).expect("0x86 present"); + let mk_dv = records_find_mk_dv(records).expect("0x86 present"); let kvn_block = aes_g(kp, &nonce); let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); ( @@ -1457,17 +1465,17 @@ mod tests { ); } - /// `mkb_find_mk_dv` must supply the ACTUAL `0x86` bytes, not a fixed block. + /// `records_find_mk_dv` must supply the ACTUAL `0x86` bytes, not a fixed block. #[test] fn mkb_find_mk_dv_returns_the_verify_records_actual_bytes() { let p = plant_variant_mkb(); assert_eq!( - mkb_find_mk_dv(&p.records), + records_find_mk_dv(&p.records), Some(p.mk_dv), "mk_dv must be the bytes the 0x86 record carries" ); - assert_ne!(mkb_find_mk_dv(&p.records), Some([0u8; 16])); - assert_ne!(mkb_find_mk_dv(&p.records), Some([1u8; 16])); + assert_ne!(records_find_mk_dv(&p.records), Some([0u8; 16])); + assert_ne!(records_find_mk_dv(&p.records), Some([1u8; 16])); // And it is the block the gate actually uses: swapping the 0x86 record // for an unrelated one must break the derivation that just succeeded. @@ -1686,6 +1694,7 @@ mod tests { /// A two-slot variant MKB whose SECOND slot is opened by a device key. struct PlantedWalk { + mkb: Vec, records: Vec, /// The device key that covers slot 1 with zero descent. dk: DeviceKey, @@ -1700,7 +1709,6 @@ mod tests { fn plant_walk_variant_mkb() -> PlantedWalk { use crate::aacs::crypto::{aes_ecb_encrypt, aes_g}; - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; const UV_DECOY: u32 = 0x0000_0800; const UV_REAL: u32 = 0x0000_0400; const U_MASK_SHIFT: u8 = 12; @@ -1794,6 +1802,7 @@ mod tests { PlantedWalk { records: walk_mkb(&mkb), + mkb, dk: DeviceKey { key: dkey, node: NODE, @@ -1852,6 +1861,62 @@ mod tests { ); } + /// A covering-but-wrong device key ahead of the right one must not hide the Media Key. + #[test] + fn device_key_search_continues_past_a_covering_wrong_key() { + let p = plant_walk_variant_mkb(); + let mut wrong = p.dk.clone(); + wrong.key[0] ^= 0xFF; + assert!(walk_processing_key(&p.records, std::slice::from_ref(&wrong)).is_some()); + assert_eq!( + derive_media_key_from_device_keys(&p.records, &[wrong.clone(), p.dk.clone()]), + Ok(p.km) + ); + assert!(derive_media_key_from_device_keys(&p.records, &[wrong]).is_err()); + assert_eq!( + derive_media_key_from_device_keys(&p.records, &[]), + Err(MediaKeyVariantError::ProcessingKeyUnavailable) + ); + } + + /// The resolver must keep searching past a covering-but-wrong key that sorts first. + #[test] + fn resolve_keys_v21_finds_the_right_key_behind_a_covering_wrong_one() { + use crate::aacs::provider::{KeyProvider, SuppliedKey}; + use crate::aacs::resolve::{ResolveContext, resolve_keys_v21}; + + let p = plant_walk_variant_mkb(); + let mut wrong = p.dk.clone(); + wrong.key[0] = 0x00; + // `Providers::device_keys` sorts by key bytes: the wrong key must come first. + assert!(wrong.key < p.dk.key, "fixture: wrong key sorts first"); + assert!(walk_processing_key(&p.records, std::slice::from_ref(&wrong)).is_some()); + + let mut uk_ro = vec![0u8; 256]; + uk_ro[3] = 0x60; + uk_ro[16] = 1; + uk_ro[17] = 1; + uk_ro[0x60 + 1] = 1; + let keys = SuppliedKey { + device_keys: vec![p.dk.clone(), wrong], + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: None, + }; + let providers: &[&dyn KeyProvider] = &[&keys]; + let vid = [0x22u8; 16]; + let r = resolve_keys_v21(&ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &vid, + providers, + mkb: Some(&p.mkb), + }) + .expect("the right key behind a covering wrong one must resolve"); + assert_eq!(r.key_source, 1); + assert_eq!(r.vuk, Some(crate::aacs::derive::derive_vuk(&p.km, &vid))); + } + /// The `[C]` §3.2.4 subset-difference gate must reject a device key on /// EVERY one of its four coordinates (node/uv/u_mask_shift/key). #[test] @@ -1974,7 +2039,6 @@ mod tests { #[test] fn walk_processing_key_authenticates_a_classical_match_through_the_verify_magic() { use crate::aacs::crypto::aes_ecb_encrypt; - const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; const UV: u32 = 0x0000_0400; const U_MASK_SHIFT: u8 = 12; diff --git a/src/bdnav/bdjo.rs b/src/bdnav/bdjo.rs index b4909025..7d91ee20 100644 --- a/src/bdnav/bdjo.rs +++ b/src/bdnav/bdjo.rs @@ -65,10 +65,6 @@ const AUTOSTART: u8 = 1; // gracefully rather than mis-reject a readable table. const MAGIC: u32 = u32::from_be_bytes(*b"BDJO"); -// Cap on applications parsed from one AMT. `number_of_applications` is a u8, so -// 255 is the hard ceiling already; the cap is a belt-and-suspenders bound. -const MAX_APPS: usize = 256; - /// Parse a `.bdjo` file's Application Management Table. Returns the list of /// application records (in file order), or `None` on any structural problem. pub(crate) fn parse(data: &[u8]) -> Option> { @@ -108,9 +104,7 @@ pub(crate) fn parse(data: &[u8]) -> Option> { r.skip(32)?; // length let num_app = r.read(8)? as usize; r.skip(8)?; // padding - if num_app > MAX_APPS { - return None; - } + // num_app is a u8 read, so it is bounded at 255 by construction. let mut apps = Vec::with_capacity(num_app); for _ in 0..num_app { apps.push(parse_app(&mut r)?); diff --git a/src/bdnav/index.rs b/src/bdnav/index.rs index 0f7d8981..ca5dfe8f 100644 --- a/src/bdnav/index.rs +++ b/src/bdnav/index.rs @@ -3,6 +3,8 @@ //! documented binary format, never executed: every field is bounds-checked and //! any malformed input yields `None` (the nav resolver then abstains). +use super::be_u16; + /// One playback/title object in `index.bdmv`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PlaybackObj { @@ -29,9 +31,6 @@ const OBJ_LEN: usize = 12; /// Sanity cap on the title count (real discs have well under this). const MAX_TITLES: usize = 4096; -fn be_u16(d: &[u8], o: usize) -> Option { - Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?])) -} fn be_u32(d: &[u8], o: usize) -> Option { Some(u32::from_be_bytes([ *d.get(o)?, @@ -153,4 +152,23 @@ mod tests { "title count over MAX_TITLES must be rejected" ); } + + #[test] + fn rejects_zero_titles() { + let d = build(hdmv_obj(0), bdj_obj(), &[]); + assert!(parse(&d).is_none(), "num_titles == 0 must be rejected"); + } + + #[test] + fn unknown_object_types_parse_as_unknown() { + let mut none = [0u8; 12]; // object_type 0 + let mut reserved = [0u8; 12]; + reserved[0] = 3 << 6; // object_type 3 + none[0] = 0x3F; // low bits set must not leak into the type + let d = build(none, reserved, &[reserved]); + let idx = parse(&d).expect("parses"); + assert_eq!(idx.first_play, PlaybackObj::Unknown); + assert_eq!(idx.top_menu, PlaybackObj::Unknown); + assert_eq!(idx.titles[0], PlaybackObj::Unknown); + } } diff --git a/src/bdnav/mobj.rs b/src/bdnav/mobj.rs index bd42ff42..e9d6efa6 100644 --- a/src/bdnav/mobj.rs +++ b/src/bdnav/mobj.rs @@ -7,6 +7,8 @@ //! byte 50. Each object is `flags`(1) + reserved(1) + `num_cmds`(u16) followed //! by `num_cmds` 12-byte navigation commands. +use super::be_u16; + // One decoded 12-byte navigation command. #[derive(Debug, Clone, Copy)] pub(crate) struct Cmd { @@ -36,10 +38,6 @@ const CMD_LEN: usize = 12; /// separate cap would be dead code. const MAX_OBJECTS: usize = 4096; -fn be_u16(d: &[u8], o: usize) -> Option { - Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?])) -} - /// Decode one 12-byte command. Caller guarantees `b.len() == 12`. fn decode_cmd(b: &[u8]) -> Cmd { let (b0, b1, b2, b3) = (b[0], b[1], b[2], b[3]); @@ -157,4 +155,13 @@ pub(crate) mod tests { // No command-count cap exists to test: `num_cmds` is a u16 (max 65535) and // each object's span is bounded against the input length, so the former // MAX_CMDS check was dead code and was removed rather than left flagged. + + #[test] + fn decode_cmd_masks_ignore_reserved_bits() { + // b2/b3 upper bits are reserved and must not leak into cmp_opt/set_opt. + let c = decode_cmd(&cmd(0xFF, 0xFF, 0xF3, 0xE5, 0, 0)); + assert_eq!((c.op_cnt, c.grp, c.sub_grp), (7, 3, 7)); + assert!(c.imm_op1 && c.imm_op2); + assert_eq!((c.branch_opt, c.cmp_opt, c.set_opt), (0x0f, 0x03, 0x05)); + } } diff --git a/src/bdnav/mod.rs b/src/bdnav/mod.rs index 97240883..753f353b 100644 --- a/src/bdnav/mod.rs +++ b/src/bdnav/mod.rs @@ -14,6 +14,10 @@ pub(crate) mod vm; use crate::sector::SectorSource; use crate::udf::UdfFs; +pub(super) fn be_u16(d: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?])) +} + // Resolve the playlist id First-Play navigation plays as the feature, among ids the caller // marks as feature candidates (`is_feature_candidate`). Returns `None` for BD-J discs, // malformed nav data, or non-convergence. diff --git a/src/bdnav/vm.rs b/src/bdnav/vm.rs index 9b3dacf1..d01d4313 100644 --- a/src/bdnav/vm.rs +++ b/src/bdnav/vm.rs @@ -214,8 +214,10 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti // rest — Terminate / Link — fall through). A logo/pre-roll whose // id isn't a feature candidate lets autoplay resume at pc + 1. 2 if matches!(c.branch_opt, 0x00..=0x02) => { - let id = dst as u16; - if is_feature(id) { + // A playlist id is 16-bit; an operand that doesn't fit is not one. + if let Ok(id) = u16::try_from(dst) + && is_feature(id) + { return Some(id); } } @@ -224,7 +226,7 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti // CMP — skip the next command when the compare is false. 1 => { let truth = match c.cmp_opt { - 0x01 => (dst & !src) == 0, + 0x01 => (src & !dst) == 0, // BC: every src bit set in dst 0x02 => dst == src, 0x03 => dst != src, 0x04 => dst >= src, @@ -576,4 +578,151 @@ mod tests { let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]); assert_eq!(resolve(&index, &mobjs, &|id| id == 1 || id == 800), None); } + + fn run_gpr0(prog: &[[u8; 12]], gprs: usize) -> Vec { + let d = build(&[prog]); + let mobjs = mobj::parse(&d).unwrap(); + let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]); + let mut vm = Vm::new(&mobjs, &index); + assert_eq!(run(&mut vm, 0, &|_| false), None); + vm.gpr[..gprs].to_vec() + } + + fn set_imm(opt: u8, reg: u32, imm: u32) -> [u8; 12] { + cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm) + } + + // CMP dst(GPR0 = a) against imm b: true falls through to PlayPL 1, false skips to 2. + fn cmp_picks_true(opt: u8, a: u32, b: u32) -> bool { + let cmp = cmd((2 << 5) | (1 << 3), 0x40, opt, 0, 0, b); + let d = build(&[&[set_imm(0x01, 0, a), cmp, play_pl(1), play_pl(2)]]); + let mobjs = mobj::parse(&d).unwrap(); + let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]); + resolve(&index, &mobjs, &|id| id == 1 || id == 2) == Some(1) + } + + #[test] + fn cmp_ops_ge_gt_le_lt_ne() { + // (cmp_opt, a, b, expected) + let cases = [ + (0x04, 5, 5, true), + (0x04, 4, 5, false), + (0x04, 6, 5, true), + (0x05, 5, 5, false), + (0x05, 6, 5, true), + (0x05, 4, 5, false), + (0x06, 5, 5, true), + (0x06, 6, 5, false), + (0x06, 4, 5, true), + (0x07, 5, 5, false), + (0x07, 4, 5, true), + (0x07, 6, 5, false), + (0x03, 5, 5, false), + (0x03, 5, 6, true), + ]; + for (opt, a, b, want) in cases { + assert_eq!(cmp_picks_true(opt, a, b), want, "opt {opt:#x} {a} vs {b}"); + } + } + + // libbluray INSN_BC: true iff every bit of src is set in dst (src & ~dst == 0). + #[test] + fn cmp_bc_tests_src_bits_within_dst() { + assert!(cmp_picks_true(0x01, 0b1110, 0b0110)); + assert!(!cmp_picks_true(0x01, 0b0100, 0b0110)); + assert!(!cmp_picks_true(0x01, 0b0110, 0b1110)); + } + + #[test] + fn set_arithmetic_and_logic_ops() { + // (set_opt, a, b, expected) + let cases = [ + (0x03, 2, 3, 5), + (0x04, 10, 3, 7), + (0x04, 3, 10, 0), + (0x05, 2, 3, 6), + (0x06, 10, 3, 3), + (0x06, 10, 0, 0xffff_ffff), + (0x07, 10, 3, 1), + (0x07, 10, 0, 0xffff_ffff), + (0x09, 0b1100, 0b1010, 0b1000), + (0x0a, 0b1100, 0b1010, 0b1110), + (0x0b, 0b1100, 0b1010, 0b0110), + (0x0c, 0, 3, 8), + (0x0d, 0xf, 1, 0xd), + (0x0e, 1, 4, 16), + (0x0f, 0x100, 4, 0x10), + ]; + for (opt, a, b, want) in cases { + let g = run_gpr0(&[set_imm(0x01, 0, a), set_imm(opt, 0, b)], 1); + assert_eq!(g[0], want, "set_opt {opt:#x} on {a}, {b}"); + } + } + + #[test] + fn branch_or_compare_on_rnd_value_abstains() { + // GPR0 = RND; then PlayPL GPR0 (branch grp, dst tainted). + let rnd = set_imm(0x08, 0, 10); + let d = build(&[&[rnd, play_pl_reg(0), play_pl(1)]]); + let mobjs = mobj::parse(&d).unwrap(); + let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]); + assert_eq!(resolve(&index, &mobjs, &|_| true), None); + // Compare with a clean dst (GPR1) and a tainted register src (GPR0). + let cmp = cmd((2 << 5) | (1 << 3), 0x00, 0x02, 0, 1, 0); + let d = build(&[&[rnd, cmp, play_pl(1), play_pl(2)]]); + let mobjs = mobj::parse(&d).unwrap(); + assert_eq!(resolve(&index, &mobjs, &|_| true), None); + } + + #[test] + fn set_from_rnd_source_taints_destination() { + // GPR0 = RND; GPR1 (clean) += GPR0 (register src); PlayPL GPR1 must abstain. + let rnd = set_imm(0x08, 0, 10); + let add = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x03, 1, 0); + let d = build(&[&[rnd, add, play_pl_reg(1), play_pl(1)]]); + let mobjs = mobj::parse(&d).unwrap(); + let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]); + assert_eq!(resolve(&index, &mobjs, &|_| true), None); + } + + #[test] + fn playpl_operand_over_16_bits_is_not_a_playlist_id() { + // 0x1_0001 must not alias playlist 1; the VM falls through to 800. + let wide = cmd((1 << 5) | 2, 0x80, 0, 0, 0x1_0001, 0); + let d = build(&[&[wide, play_pl(800)]]); + let mobjs = mobj::parse(&d).unwrap(); + let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]); + assert_eq!( + resolve(&index, &mobjs, &|id| id == 1 || id == 800), + Some(800) + ); + } + + #[test] + fn power_on_psr_defaults_are_visible_to_compares() { + // (psr, expected power-on value) + let cases: [(u32, u32); 12] = [ + (0, 1), + (1, 0xff), + (3, 1), + (4, 0xffff), + (5, 0xffff), + (6, 0), + (7, 0), + (8, 0), + (20, 2), + (31, 0x0003_0200), + (48, 0xffff_ffff), + (62, 0), + ]; + for (psr, want) in cases { + // CMP PSR == want (imm): true plays 1, false plays 2. + let cmp = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0000 | psr, want); + let d = build(&[&[cmp, play_pl(1), play_pl(2)]]); + let mobjs = mobj::parse(&d).unwrap(); + let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]); + let got = resolve(&index, &mobjs, &|id| id == 1 || id == 2); + assert_eq!(got, Some(1), "PSR{psr} != {want:#x}"); + } + } } diff --git a/src/clpi.rs b/src/clpi.rs index ba05a708..d464b40f 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -36,7 +36,8 @@ pub(crate) struct ClpiStream { /// Parse a CLPI file from raw bytes. pub fn parse(data: &[u8]) -> Result { - if data.len() < 40 { + // 60 bytes: the ClipInfo section through source_packet_count at [56..60]. + if data.len() < 60 { return Err(Error::ClpiParse); } @@ -45,20 +46,14 @@ pub fn parse(data: &[u8]) -> Result { } // Header offsets - let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize; let prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize; // ClipInfo section at offset 40 // source_packet_count at offset 40 + 4(len) + 2(reserved) + 1(stream_type) + 1(app_type) + 4(reserved) + 4(ts_rate) - let source_packet_count = if data.len() >= 60 { - u32::from_be_bytes([data[56], data[57], data[58], data[59]]) - } else { - 0 - }; + let source_packet_count = u32::from_be_bytes([data[56], data[57], data[58], data[59]]); // Parse ProgramInfo (per-stream language + codec), best-effort: malformed // program_info doesn't fail the parse, just yields an empty streams list. - // EP map is unaffected — sector-range lookups still work. let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() { parse_program_info(&data[prog_info_start..]) } else { @@ -72,7 +67,7 @@ pub fn parse(data: &[u8]) -> Result { } // Parse the ProgramInfo section: per-stream (pid, coding_type, language, codec sub-fields). -// Returns Vec::new() on any structural mismatch. +// On a structural mismatch, returns the fully validated entries parsed so far. fn parse_program_info(data: &[u8]) -> Vec { use crate::consts::coding_type as c; let mut out = Vec::new(); @@ -112,16 +107,15 @@ fn parse_program_info(data: &[u8]) -> Vec { // Video — MPEG-2, H.264, HEVC c::MPEG2_VIDEO | c::H264 | c::HEVC => {} // Primary audio — LPCM, AC-3, DTS, TrueHD, AC-3+, DTS-HD HR, DTS-HD MA - c::LPCM..=c::DTS_HD_MA => { + // and secondary audio (AC-3+ secondary, DTS-HD secondary) + c::LPCM..=c::DTS_HD_MA | c::AC3_PLUS_SECONDARY | c::DTS_HD_SECONDARY => { if sci.len() >= 5 { language = String::from_utf8_lossy(&sci[2..5]).to_string(); } } - // Secondary audio (AC-3+ secondary, DTS-HD secondary) - c::AC3_PLUS_SECONDARY | c::DTS_HD_SECONDARY => { - if sci.len() >= 5 { - language = String::from_utf8_lossy(&sci[2..5]).to_string(); - } + // TextST: coding_type + character_code + 3-byte language + c::TEXT_SUBTITLE if sci.len() >= 5 => { + language = String::from_utf8_lossy(&sci[2..5]).to_string(); } // PG, IG: coding_type + 3-byte language [+ char_code for PG] c::PG | c::IG if sci.len() >= 4 => { @@ -176,16 +170,14 @@ mod tests { #[test] fn parse_truncated_clipinfo_no_panic() { // 57/58/59-byte CLPI with valid magic: passes the data.len() < 40 - // guard but data[56..60] needs 60 bytes. Must not panic. + // guard but data[56..60] needs 60 bytes. Must error, not panic. for len in 40..60usize { let mut data = vec![0u8; len]; data[0..4].copy_from_slice(b"HDMV"); if len >= 8 { data[4..8].copy_from_slice(b"0200"); } - let clip = parse(&data).expect("short CLPI should parse, not panic"); - // source_packet_count is unreadable below 60 bytes → 0. - assert_eq!(clip.source_packet_count, 0); + assert!(parse(&data).is_err(), "len {len} lacks source_packet_count"); } } @@ -267,8 +259,8 @@ mod tests { assert!(parse(&data).is_err()); } - /// Under-40-byte input is rejected before any field read - /// (`data.len() < 40` guard). + /// Input shorter than the 60-byte ClipInfo head is rejected before any + /// field read (`data.len() < 60` guard). #[test] fn under_40_bytes_rejected() { assert!(parse(&[0u8; 39]).is_err()); @@ -482,6 +474,60 @@ mod tests { assert!(parse_program_info(&data).is_empty()); } + /// TextST (0x92): coding_type + character_code + 3-byte language, so the + /// language sits at sci[2..5]. + #[test] + fn program_info_text_subtitle_lang_offset() { + let sci = vec![0x92u8, 0x01, b'd', b'e', b'u']; + let pi = build_program_info(&[(0x1800, sci)]); + let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse"); + assert_eq!(clip.streams[0].coding_type, 0x92); + assert_eq!(clip.streams[0].language, "deu"); + } + + /// IG (0x91) shares the PG layout: language at sci[1..4]. + #[test] + fn program_info_ig_lang_offset() { + let sci = vec![0x91u8, b'e', b's', b'p']; + let pi = build_program_info(&[(0x1400, sci)]); + let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse"); + assert_eq!(clip.streams[0].language, "esp"); + } + + /// Streams from every program are collected, in order. + #[test] + fn program_info_multiple_programs_all_collected() { + let mut body = vec![0u8, 2]; // reserved, num_programs = 2 + for (pid, lang) in [(0x1200u16, b"fra"), (0x1201u16, b"jpn")] { + body.extend_from_slice(&[0u8; 6]); // spn + pmt_pid + body.extend_from_slice(&[1, 0]); // num_streams, num_groups + body.extend_from_slice(&pid.to_be_bytes()); + body.push(4); + body.push(0x90); + body.extend_from_slice(lang); + } + let mut pi = (body.len() as u32).to_be_bytes().to_vec(); + pi.extend_from_slice(&body); + let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse"); + assert_eq!(clip.streams.len(), 2); + assert_eq!(clip.streams[0].language, "fra"); + assert_eq!(clip.streams[1].pid, 0x1201); + assert_eq!(clip.streams[1].language, "jpn"); + } + + /// A trailing CPI section after ProgramInfo does not disturb parsing. + #[test] + fn cpi_section_present_still_parses() { + let pi = build_program_info(&[(0x1200, vec![0x90u8, b'f', b'r', b'a'])]); + let cpi = [0u8, 0, 0, 2, 0, 0]; + let data = build_clpi_with_proginfo(777, &pi, Some(&cpi)); + let clip = parse(&data).expect("parse"); + assert_eq!(clip.source_packet_count, 777); + assert_eq!(clip.streams.len(), 1); + let clip = parse(&build_clpi(5, Some(&cpi))).expect("parse"); + assert_eq!(clip.source_packet_count, 5); + } + // ───────────────────────────────────────────────────────────────────── // parse_cpi — low-level fixtures // ───────────────────────────────────────────────────────────────────── diff --git a/src/consts.rs b/src/consts.rs index 8ba87a89..654563d7 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -111,6 +111,8 @@ pub mod pes_stream_id { /// (rate/bound bounds), never an elementary stream. On a DVD NAV pack it /// follows the pack header, so it lands at sector offset 0x11. pub const SYSTEM_HEADER: u8 = 0xBB; + /// program_stream_map — the `00 00 01 BC` structural table (MS-8). + pub const PROGRAM_STREAM_MAP: u8 = 0xBC; /// private_stream_1 — AC-3 / DTS / LPCM / PGS subtitle payloads. pub const PRIVATE_STREAM_1: u8 = 0xBD; /// padding_stream — stuffing bytes only, no payload to demux. diff --git a/src/css/keyless.rs b/src/css/keyless.rs index 7dc563da..2f091c30 100644 --- a/src/css/keyless.rs +++ b/src/css/keyless.rs @@ -144,11 +144,10 @@ fn recover_title_key_from_plain( /// `plain` is the expected plaintext at byte 0x80 (≥10 bytes); returns the key only /// if it descrambles the sector back to `plain`, guarding a rare spurious LFSR match. /// -/// TEST-ONLY: the production crack path uses [`crack_title_key`], which derives -/// its own crib. `recover_title_key` is a known-plaintext helper exercised only -/// by this module's unit tests and `tests/crypto_tests.rs`. It stays `pub` so -/// that integration test (a separate crate linking the non-`cfg(test)` build) -/// can reach it — do NOT gate it behind `#[cfg(test)]`, which would break it. +/// TEST-ONLY: production cracks via [`crack_title_key`], which derives its own +/// crib. This helper is used by unit tests and `tests/crypto_tests.rs`; it stays +/// `pub` (not `#[cfg(test)]`) so that separate integration-test crate can reach it. +#[doc(hidden)] pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> { if sector.len() < SECTOR_BYTES || plain.len() < 10 { return None; @@ -157,13 +156,7 @@ pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> { return None; } - let seed: [u8; 5] = [ - sector[SEED_OFFSET], - sector[SEED_OFFSET + 1], - sector[SEED_OFFSET + 2], - sector[SEED_OFFSET + 3], - sector[SEED_OFFSET + 4], - ]; + let seed = sector_seed(sector); let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10]; let key = recover_title_key_from_plain(crypted, plain, &seed)?; @@ -175,6 +168,12 @@ pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> { } } +fn sector_seed(sector: &[u8]) -> [u8; 5] { + let mut seed = [0u8; 5]; + seed.copy_from_slice(§or[SEED_OFFSET..SEED_OFFSET + 5]); + seed +} + /// Verify a title key by descrambling a copy of `sector` and checking the /// known plaintext reappears at byte 0x80. fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool { @@ -185,13 +184,9 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool { test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n] } -/// Find a repeating pattern just before the encrypted region and assume the -/// plaintext at 0x80 continues it — the known-plaintext step of the recovery. -/// Scans cleartext `sec[0x00..0x80]` for the longest run that repeats -/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at -/// least two full cycles), the known plaintext at 0x80 is taken to be the -/// periodic run continuing forward, and [`recover_title_key_from_plain`] is -/// applied. +/// Crack the CSS title key from one scrambled sector with no external crib. +/// The crib is the periodic cleartext run before 0x80 (see `attack_crib`), +/// continued into the encrypted region and fed to `recover_title_key_from_plain`. pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> { if sector.len() < SECTOR_BYTES { return None; @@ -263,14 +258,8 @@ pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> { fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> { let plain = attack_crib(sector)?; - let seed: [u8; 5] = [ - sector[SEED_OFFSET], - sector[SEED_OFFSET + 1], - sector[SEED_OFFSET + 2], - sector[SEED_OFFSET + 3], - sector[SEED_OFFSET + 4], - ]; - let crypted = §or[0x80..0x80 + 10]; + let seed = sector_seed(sector); + let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10]; if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) { // Verify against the same predicted plaintext. if descramble_matches(sector, &key, &plain) { diff --git a/src/css/lfsr.rs b/src/css/lfsr.rs index 08236753..1ada39c0 100644 --- a/src/css/lfsr.rs +++ b/src/css/lfsr.rs @@ -12,19 +12,13 @@ use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5}; /// Descramble a CSS-encrypted DVD sector in place. /// -/// Registers are seeded from `title_key XOR sector_seed` (bytes -/// `0x54..0x59`). Only the body, bytes `0x80..0x800`, is transformed: -/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`. The scramble flag at byte -/// `0x14` (bits 4-5) is CLEARED after unscrambling, so a descrambled sector -/// reads as `sector[0x14] & 0x30 == 0`. +/// Seeded from `title_key XOR sector_seed` (bytes `0x54..0x59`); transforms only +/// the body `0x80..0x800`: `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`. +/// Flag bits 4-5 at byte `0x14` are cleared afterwards. /// -/// No-op (returns without modifying `sector`) if `sector.len() < 2048` or the scramble flag -/// bits are already zero. +/// No-op if `sector.len() < 2048` or the scramble flag bits are already zero. +#[doc(hidden)] pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) { - debug_assert!( - sector.len() >= 2048, - "descramble_sector: buffer shorter than one 2048-byte sector" - ); if sector.len() < 2048 { return; } @@ -138,10 +132,10 @@ mod tests { assert_eq!(sector, original); } - // Regression vector pinning the implementation's deterministic output. key = 42 13 37 BE - // EF, seed = DE AD BE EF 42, body = 0xAA. + // Regression vector pinning this implementation's own output (NOT an independent CSS + // known-answer). key = 42 13 37 BE EF, seed = DE AD BE EF 42, body = 0xAA. #[test] - fn descramble_produces_the_reference_css_vector() { + fn descramble_output_is_pinned_regression_vector() { let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let mut sector = vec![0xAAu8; 2048]; sector[0x14] = 0x30; @@ -153,12 +147,12 @@ mod tests { 0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98, 0xA0, 0x31 ], - "descramble body head must match the reference CSS vector" + "descramble body head changed from the pinned output" ); assert_eq!( §or[0x7F8..0x800], &[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC], - "descramble body tail must match the reference CSS vector" + "descramble body tail changed from the pinned output" ); } @@ -226,8 +220,7 @@ mod tests { ); } - // TAB1 is a substitution table used in key mangling; verify it is a - // permutation of 0..255 (no two inputs map to the same output). + // TAB1 is a substitution table; verify it is a permutation of 0..255. #[test] fn css_tab1_is_permutation() { let mut seen = [false; 256]; @@ -236,15 +229,6 @@ mod tests { assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v); seen[v] = true; } - // Check involution property: TAB1[TAB1[x]] should map back predictably - // TAB1 is not necessarily a strict involution, but we verify the - // composition TAB1[TAB1[x]] is also a permutation - let mut seen2 = [false; 256]; - for i in 0..256 { - let v = TAB1[TAB1[i] as usize] as usize; - assert!(!seen2[v], "TAB1[TAB1[x]] maps two inputs to {:#04x}", v); - seen2[v] = true; - } } /// css_tab4_is_bit_reversal diff --git a/src/css/mod.rs b/src/css/mod.rs index b87b6a2e..514b8e8b 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -26,6 +26,9 @@ use crate::sector::SectorSource; // 50_000-sector budget. Resets to 0 on any readable batch. const CSS_LOCKED_BAIL: u32 = 64; +// Sector budget for one crack scan. +const MAX_TRIES: u32 = 50_000; + // Test-only: how many times descramble_region called the expensive re-crack. // Pins a WORK bound (at most one per contiguous false-positive run). #[cfg(test)] @@ -80,7 +83,7 @@ pub fn crack_key( extents: &[Extent], batch_sectors: u16, ) -> Option { - crack_key_scan(reader, extents, batch_sectors, None).into_state() + crack_key_outcome(reader, extents, batch_sectors, None).into_state() } /// Outcome of a CSS crack scan. Only `Unencrypted` lets a caller treat the data as @@ -132,7 +135,9 @@ pub fn crack_key_outcome( batch_sectors: u16, halt: Option<&crate::halt::Halt>, ) -> CrackOutcome { - crack_key_scan(reader, extents, batch_sectors, halt) + crack_key_scan_with(reader, extents, batch_sectors, halt, |s| { + is_scrambled_pack(s).then_some(0x14) + }) } // The SINGLE place every DVD read path obtains a title key when the caller supplied none. A @@ -250,20 +255,6 @@ thread_local! { pub(crate) static CRACK_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -// The crack scan, returning the full CrackOutcome. Tracks `saw_scrambled` so -// a scrambled-but-uncracked disc is distinguished from a genuinely -// unencrypted one (crack_key's Option wrapper collapses both to None). -fn crack_key_scan( - reader: &mut dyn SectorSource, - extents: &[Extent], - batch_sectors: u16, - halt: Option<&crate::halt::Halt>, -) -> CrackOutcome { - crack_key_scan_with(reader, extents, batch_sectors, halt, |s| { - is_scrambled_pack(s).then_some(0x14) - }) -} - // The crack scan with its test: `scrambled_at` gives a scrambled pack's flags offset (always // 0x14 on the disc path; `ps_scrambled_at` for mpg:// files). fn crack_key_scan_with( @@ -288,7 +279,6 @@ fn crack_key_scan_with( .map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count))) .reduce(|(amin, amax), (bmin, bmax)| (amin.min(bmin), amax.max(bmax))); let mut tried = 0u32; - let max_tries = 50_000u32; let mut buf = vec![0u8; batch as usize * 2048]; let mut hb = crate::progress::Heartbeat::new("css_crack"); // Track whether ANY scrambled sector was observed: if the budget is @@ -306,7 +296,7 @@ fn crack_key_scan_with( 'outer: for (extent_idx, ext) in extents.iter().enumerate() { let mut i = 0u32; - while i < ext.sector_count && tried < max_tries { + while i < ext.sector_count && tried < MAX_TRIES { // Cooperative cancellation — poll once per batch, the same cadence // sweep/patch use, so a Stop / watchdog can interrupt the scan. if halt.is_some_and(|h| h.is_cancelled()) { @@ -317,7 +307,7 @@ fn crack_key_scan_with( // Liveness beacon: a long scan over a damaged disc stays visible. // The heartbeat is time-throttled; only when it actually beats do // we emit the crack-specific context (tried/lba/extent_idx). - if hb.tick(tried as u64, max_tries as u64) { + if hb.tick(tried as u64, MAX_TRIES as u64) { tracing::debug!( target: "freemkv::heartbeat", phase = "css_crack", @@ -372,7 +362,7 @@ fn crack_key_scan_with( }); } } - if tried >= max_tries { + if tried >= MAX_TRIES { break 'outer; } } @@ -425,18 +415,18 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { lfsr::descramble_sector(&state.title_key, sector); } +// How many mismatches a failed re-crack's "false positive" verdict covers before the next retry — +// bounded so a genuine key change right after a false-positive run is still picked up. +const RECRACK_RETRY_EVERY: u32 = 16; + /// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB region boundary. /// `title_key` is a CACHE of the last crack: validated against the clear-header crib on every /// scrambled sector and re-cracked on a miss. A crib-less sector rides the cached key. /// /// # Errors /// -/// Never returns `Err` — `Result` only matches the decrypt seam this is -/// dispatched from (see [`crate::decrypt::decrypt_sectors`]). -// How many mismatches a failed re-crack's "false positive" verdict covers before the next retry — -// bounded so a genuine key change right after a false-positive run is still picked up. -const RECRACK_RETRY_EVERY: u32 = 16; - +/// Never returns `Err` and always returns `Ok(0)` (CSS drops no sectors); `Result` only +/// matches the decrypt seam this is dispatched from (see [`crate::decrypt::decrypt_sectors`]). pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result { // `is_scrambled_pack`, NOT the looser `is_scrambled`: this sees arbitrary // regions (IFO/UDF/ISO 9660) where raw byte 0x14 isn't a reliable flag. @@ -525,6 +515,7 @@ fn descramble_one(chunk: &mut [u8], title_key: &mut [u8; 5], mismatches_since_at /// code, which every genuinely scrambled VOB sector carries and no IFO sector /// does. This stays public only because an integration test asserts the flag /// extraction directly; it has no production callers. +#[doc(hidden)] pub fn has_scramble_flag_bits(sector: &[u8]) -> bool { sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0 } @@ -565,19 +556,10 @@ mod tests { // does NOT fail the rip (a crib false positive, not a stale key). #[test] fn a_crib_false_positive_keeps_the_cached_key_rather_than_failing() { - // Header periodic enough to yield a crib, body random enough that no - // LFSR seed reproduces it — crib mismatch, re-crack fails. - let mut sector = [0u8; 2048]; - sector[0x14] = 0x30; - for (i, b) in sector.iter_mut().enumerate().take(0x80).skip(0x20) { - *b = (i % 4) as u8; - } - for (i, b) in sector.iter_mut().enumerate().skip(0x80) { - *b = ((i * 37 + 11) % 251) as u8; - } + let mut sector = false_positive_scrambled_pack(); assert!( - has_scramble_flag_bits(§or), - "fixture must be a scrambled sector" + is_scrambled_pack(§or), + "fixture must be a real scrambled pack, or descramble_region skips it" ); assert!( keyless::attack_crib(§or).is_some(), @@ -587,17 +569,29 @@ mod tests { keyless::crack_title_key(§or).is_none(), "fixture must be uncrackable, or the failure branch is never entered" ); + let ciphertext = sector; let key_before = [0xAAu8; 5]; let mut key = key_before; + RECRACK_ATTEMPTS.with(|c| c.set(0)); let out = descramble_region(&mut sector, &mut key) .expect("a crib false positive must NOT fail the rip"); + assert_eq!( + RECRACK_ATTEMPTS.with(|c| c.get()), + 1, + "the sector must reach the failed re-crack, not be skipped" + ); + assert_eq!( + sector[0x14] & 0x30, + 0, + "the cached key must still be applied" + ); // "No loss term" belongs to the SEAM, not this function: assert it one // level up (at `decrypt_sectors`) where the value is assembled and // returned, so the arm dispatch and plumbing are exercised too. assert_eq!(out, 0); - let mut seam_sector = sector; + let mut seam_sector = ciphertext; let mut seam_keys = crate::decrypt::DecryptKeys::Css { title_key: key }; assert_eq!( crate::decrypt::decrypt_sectors(&mut seam_sector, &mut seam_keys, 0) @@ -605,6 +599,11 @@ mod tests { 0, "CSS reports no loss term of its own through decrypt_sectors" ); + assert_eq!( + seam_sector[0x14] & 0x30, + 0, + "the seam must process the pack too" + ); assert_eq!( key, key_before, "a failed re-crack must leave the cached key in place — it is still \ @@ -736,6 +735,39 @@ mod tests { // ── has_scramble_flag_bits ───────────────────────────────────────────── + // Length guards on untrusted regions: a short buffer or trailing partial chunk must be + // skipped, never indexed (a panic here would abort the rip). + #[test] + fn short_buffers_are_not_scrambled_packs_and_do_not_panic() { + let pack = false_positive_scrambled_pack(); + for len in [0, 1, 0x14, 0x15, 1024, 2047] { + assert!(!is_scrambled_pack(&pack[..len]), "len {len}"); + assert!(ps_scrambled_at(&pack[..len]).is_none(), "len {len}"); + assert!(!is_scrambled_ps_pack(&pack[..len]), "len {len}"); + } + } + + #[test] + fn descramble_region_leaves_a_trailing_partial_chunk_alone() { + let pack = false_positive_scrambled_pack(); + let mut buf = pack.to_vec(); + buf.extend_from_slice(&pack[..1500]); + let tail = buf[2048..].to_vec(); + let mut key = [0xAAu8; 5]; + descramble_region(&mut buf, &mut key).expect("never fails"); + assert_eq!( + buf[2048..], + tail[..], + "a partial trailing chunk is left as is" + ); + assert_eq!(buf[0x14] & 0x30, 0, "the whole pack before it is processed"); + let mut short = pack[..1500].to_vec(); + let before = short.clone(); + descramble_region(&mut short, &mut key).expect("never fails"); + descramble_ps_region(&mut short, &mut key); + assert_eq!(short, before); + } + // A buffer shorter than one sector reports false WITHOUT indexing 0x14 // (short-circuited before the flag read). #[test] @@ -934,6 +966,8 @@ mod tests { /// Every read fails with CSS-locked sense `05/6F/03` (drive refusing /// scrambled reads because the bus-auth gate isn't open). lock_all: bool, + /// LBA ranges whose reads fail CSS-locked (a gate that opens elsewhere). + locked: Vec>, /// When set, the sector at `crackable.0` is served as a full /// crackable scrambled sector instead of the uniform `flag_byte` /// fill, so the scan can reach `CrackOutcome::Cracked`. @@ -954,6 +988,7 @@ mod tests { flag_byte, fail_all: false, lock_all: false, + locked: Vec::new(), crackable: None, short_read: None, stream_id: 0x00, @@ -991,7 +1026,7 @@ mod tests { _recovery: bool, ) -> Result { self.reads.borrow_mut().push(lba); - if self.lock_all { + if self.lock_all || self.locked.iter().any(|r| r.contains(&lba)) { return Err(Error::DiscRead { sector: lba as u64, status: Some(2), @@ -1057,7 +1092,7 @@ mod tests { start_lba: 100, sector_count: 4, }]; - let _ = crack_key_scan(&mut src, &ext, 4, None); + let _ = crack_key_outcome(&mut src, &ext, 4, None); let reads = src.reads.borrow().clone(); assert_eq!( reads, @@ -1078,7 +1113,7 @@ mod tests { start_lba: 0, sector_count: 8, }]; - let outcome = crack_key_scan(&mut src, &ext, 4, None); + let outcome = crack_key_outcome(&mut src, &ext, 4, None); assert!( outcome.is_scrambled_uncracked(), "nothing was read → no verdict, fail closed (never Unencrypted)" @@ -1105,7 +1140,7 @@ mod tests { start_lba: 0, sector_count: 60_000, }]; - let _ = crack_key_scan(&mut src, &ext, 4, None); + let _ = crack_key_outcome(&mut src, &ext, 4, None); let reads = src.reads.borrow().len(); assert!( reads <= MAX_TRIES, @@ -1319,9 +1354,47 @@ mod tests { }]; let _ = crack_key_outcome(&mut src, &extents, 1, None); let n = src.reads.borrow().len(); + assert_eq!( + n, CSS_LOCKED_BAIL as usize, + "locked scan bails at exactly {CSS_LOCKED_BAIL} reads, not 10000; read {n}" + ); + } + + // A locked run shorter than the bail, then an open gate, must reach the sector. + #[test] + fn a_short_locked_run_before_an_open_gate_still_cracks() { + let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + let last = CSS_LOCKED_BAIL - 1; + let mut src = MockSource::new(0x00); + src.locked.push(0..last); + src.crackable = Some((last, crackable_sector(&key, &[1, 2, 3, 4, 5], 8))); + let extents = [Extent { + start_lba: 0, + sector_count: 10_000, + }]; + let outcome = crack_key_outcome(&mut src, &extents, 1, None); assert!( - n <= (CSS_LOCKED_BAIL as usize) + 1, - "locked scan early-bails near {CSS_LOCKED_BAIL}, not 10000; read {n}" + matches!(outcome, CrackOutcome::Cracked(_)), + "got {outcome:?}" + ); + } + + // A readable batch resets the locked run: two sub-bail runs split by one open read never bail. + #[test] + fn a_readable_batch_resets_the_locked_run() { + let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + let run = CSS_LOCKED_BAIL - 1; + let mut src = MockSource::new(0x00); + src.locked = vec![0..run, run + 1..2 * run + 1]; + src.crackable = Some((2 * run + 1, crackable_sector(&key, &[1, 2, 3, 4, 5], 8))); + let extents = [Extent { + start_lba: 0, + sector_count: 10_000, + }]; + let outcome = crack_key_outcome(&mut src, &extents, 1, None); + assert!( + matches!(outcome, CrackOutcome::Cracked(_)), + "got {outcome:?}" ); } diff --git a/src/decrypt.rs b/src/decrypt.rs index a4702d33..4bc9d59a 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -30,6 +30,9 @@ static DECRYPT_THREADS: AtomicUsize = AtomicUsize::new(0); // Current rayon pool. `set_decrypt_threads` swaps it without leaking the old one; in-flight // calls hold their own `Arc` via `decrypt_pool` and finish on it. static DECRYPT_POOL: RwLock>> = RwLock::new(None); +// Set once a pool build fails (e.g. OS thread limit) so later reads go serial without retrying +// the spawn every buffer; `set_decrypt_threads` clears it. +static POOL_BUILD_FAILED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Configure how many threads to use for AACS unit decryption. A value /// of `0` resets to the env / default resolution. `1` forces serial. @@ -47,34 +50,54 @@ pub fn set_decrypt_threads(n: usize) { // skipping the swap would keep the STALE pool alive, making the setting no-op. let mut guard = DECRYPT_POOL.write().unwrap_or_else(|e| e.into_inner()); *guard = None; + POOL_BUILD_FAILED.store(false, Ordering::Relaxed); } // Get (or lazily build) the pool; `Arc` so in-flight work survives a concurrent // `set_decrypt_threads` swap. `None` if unbuildable (e.g. OS thread limit) — caller falls back // to serial. fn decrypt_pool() -> Option> { + pool_or_build(&DECRYPT_POOL, &POOL_BUILD_FAILED, || { + rayon::ThreadPoolBuilder::new() + .num_threads(decrypt_threads()) + .thread_name(|i| format!("freemkv-decrypt-{i}")) + .build() + .ok() + }) +} + +// `slot` holds the pool; `failed` remembers a failed `build` so it is attempted (and logged) once. +fn pool_or_build( + slot: &RwLock>>, + failed: &std::sync::atomic::AtomicBool, + build: impl FnOnce() -> Option, +) -> Option> { // Fast path: pool already built. A poisoned read lock still yields a // usable guard (the pool Arc is immutable once stored). { - let guard = DECRYPT_POOL.read().unwrap_or_else(|e| e.into_inner()); + let guard = slot.read().unwrap_or_else(|e| e.into_inner()); if let Some(pool) = guard.as_ref() { return Some(Arc::clone(pool)); } } + if failed.load(Ordering::Relaxed) { + return None; + } // Slow path: build a new one under the write lock, recovering the guard on // poisoning (a prior panic) rather than propagating a secondary panic — we // simply rebuild. Double-check after acquiring in case another caller won. - let mut guard = DECRYPT_POOL.write().unwrap_or_else(|e| e.into_inner()); + let mut guard = slot.write().unwrap_or_else(|e| e.into_inner()); if let Some(pool) = guard.as_ref() { return Some(Arc::clone(pool)); } - let n = decrypt_threads(); - let pool = rayon::ThreadPoolBuilder::new() - .num_threads(n) - .thread_name(|i| format!("freemkv-decrypt-{i}")) - .build() - .ok() - .map(Arc::new)?; + if failed.load(Ordering::Relaxed) { + return None; + } + let Some(pool) = build().map(Arc::new) else { + failed.store(true, Ordering::Relaxed); + tracing::warn!("decrypt thread pool could not be built; decrypting serially"); + return None; + }; *guard = Some(Arc::clone(&pool)); Some(pool) } @@ -187,6 +210,8 @@ pub struct AacsKeyMap { // (start_lba, end_lba, key_idx, phase). An LBA in NO range is passed through // untouched — the map is a positive list of "this key here", nothing more. ranges: Vec<(u32, u32, usize, Phase)>, + // Parallel to `ranges`: the original start each piece's unit parity is measured from. + anchors: Vec, // Distinct, sorted key indices the map selects — derived from `ranges` once at // construction so the per-batch decrypt bounds check does not re-allocate/sort // it on every read. Kept in sync by building both in `from_ranges_phased`. @@ -210,11 +235,46 @@ impl AacsKeyMap { /// for base/CPS). Ranges are sorted; an LBA in no range is passed through. pub(crate) fn from_ranges_phased(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Self { ranges.sort_by_key(|&(start, _, _, _)| start); + // Disjointness (entry_for relies on it): each boundary-to-boundary stretch goes to + // the covering range that starts last; `anchors` keeps the ORIGINAL start that + // FMTS unit parity is measured from. + let mut bounds: Vec = ranges.iter().flat_map(|r| [r.0, r.1]).collect(); + bounds.sort_unstable(); + bounds.dedup(); + let mut disjoint: Vec<(u32, u32, usize, Phase)> = Vec::with_capacity(ranges.len()); + let mut anchors: Vec = Vec::with_capacity(ranges.len()); + let mut srcs: Vec = Vec::with_capacity(ranges.len()); + let mut overlapped = false; + for w in bounds.windows(2) { + let (a, b) = (w[0], w[1]); + let mut cover = (0..ranges.len()).rev().filter(|&i| { + let r = ranges[i]; + r.0 <= a && b <= r.1 && r.0 < r.1 + }); + let Some(win) = cover.next() else { continue }; + overlapped |= cover.next().is_some(); + if srcs.last() == Some(&win) + && let Some(last) = disjoint.last_mut() + && last.1 == a + { + last.1 = b; + continue; + } + let r = ranges[win]; + disjoint.push((a, b, r.2, r.3)); + anchors.push(r.0); + srcs.push(win); + } + if overlapped { + tracing::warn!("overlapping AACS key ranges; the later-starting range takes over"); + } + let ranges = disjoint; let mut key_indices: Vec = ranges.iter().map(|&(_, _, i, _)| i).collect(); key_indices.sort_unstable(); key_indices.dedup(); Self { ranges, + anchors, key_indices, } } @@ -224,20 +284,17 @@ impl AacsKeyMap { /// the unit through untouched). O(log n). `range_start_lba` lets the mapped /// decrypt compute a unit's parity WITHIN a forensic segment (`Even`/`Odd`). pub fn entry_for(&self, lba: u32) -> Option<(usize, Phase, u32)> { - match self + let i = match self .ranges .binary_search_by(|&(start, _, _, _)| start.cmp(&lba)) { - Ok(i) => { - let (start, _, idx, ph) = self.ranges[i]; - Some((idx, ph, start)) - } - Err(0) => None, - Err(i) => { - let (start, end, idx, ph) = self.ranges[i - 1]; - (lba >= start && lba < end).then_some((idx, ph, start)) - } - } + Ok(i) => i, + Err(0) => return None, + Err(i) => i - 1, + }; + let (start, end, idx, ph) = self.ranges[i]; + (lba >= start && lba < end) + .then(|| (idx, ph, self.anchors.get(i).copied().unwrap_or(start))) } /// The unit-key index for the aligned unit at `lba`, or `None` when no range @@ -412,7 +469,7 @@ fn apply_aacs_map( }; let unit_len = aacs::content::ALIGNED_UNIT_LEN; - let unit_sectors = (unit_len / 2048) as u32; + let unit_sectors = aacs::content::ALIGNED_UNIT_SECTORS; // Validate every selectable index up front (fail loud) so the per-unit hot // loop can index without bounds churn and a resolver gap never silently @@ -1744,6 +1801,9 @@ mod tests { let ul = aacs::content::ALIGNED_UNIT_LEN; let usz = (ul / 2048) as u32; let key2 = [0x78u8; 16]; + // Force the parallel branch (else a 1-core runner / FREEMKV_THREADS=1 runs serial). + set_decrypt_threads(4); + assert!(decrypt_threads() > 1); let n = PARALLEL_MIN_UNITS * 4; // well past the parallel threshold let half = n / 2; // DISTINCT plaintext per unit and a second key for the upper half, so a @@ -1776,6 +1836,80 @@ mod tests { "unit {i} must recover to its own plaintext on the parallel path" ); } + set_decrypt_threads(0); + } + + // A failed pool build is remembered: later calls go serial without re-running the builder. + #[test] + fn failed_pool_build_is_cached_not_retried() { + let slot = RwLock::new(None); + let failed = std::sync::atomic::AtomicBool::new(false); + let calls = std::cell::Cell::new(0u32); + for _ in 0..3 { + let got = pool_or_build(&slot, &failed, || { + calls.set(calls.get() + 1); + None + }); + assert!(got.is_none()); + } + assert_eq!( + calls.get(), + 1, + "the failing builder must run once, not per call" + ); + // A successful build is stored and reused. + let slot = RwLock::new(None); + let ok = std::sync::atomic::AtomicBool::new(false); + let build = || rayon::ThreadPoolBuilder::new().num_threads(1).build().ok(); + let first = pool_or_build(&slot, &ok, build).expect("pool builds"); + let second = pool_or_build(&slot, &ok, || None).expect("pool reused"); + assert!(Arc::ptr_eq(&first, &second)); + } + + // Overlapping ranges are made disjoint (later start wins) so entry_for stays sound. + #[test] + fn nested_key_range_keeps_outer_tail_and_phase_anchor() { + let map = + AacsKeyMap::from_ranges_phased(vec![(0, 100, 0, Phase::Even), (20, 60, 1, Phase::All)]); + assert_eq!(map.key_idx_for(80), Some(0)); + assert_eq!(map.key_idx_for(40), Some(1)); + assert_eq!(map.entry_for(80), Some((0, Phase::Even, 0))); + assert_eq!(map.entry_for(10), Some((0, Phase::Even, 0))); + } + + /// Every sector of every input range resolves to some key. + #[test] + fn every_sector_of_every_input_range_gets_a_key() { + let input = vec![ + (0, 100, 0), + (20, 30, 1), + (25, 60, 2), + (90, 120, 3), + (110, 115, 4), + ]; + let map = AacsKeyMap::from_ranges(input.clone()); + for (s, e, _) in input { + for lba in s..e { + assert!(map.key_idx_for(lba).is_some(), "lba {lba} lost its key"); + } + } + assert_eq!(map.key_idx_for(60), Some(0)); + assert_eq!(map.key_idx_for(116), Some(3)); + assert_eq!(map.key_idx_for(120), None); + } + + #[test] + fn overlapping_key_ranges_are_made_disjoint() { + let map = AacsKeyMap::from_ranges(vec![(0, 100, 0), (20, 30, 1), (25, 60, 2), (60, 60, 3)]); + let r = map.ranges(); + assert!( + r.windows(2).all(|w| w[0].1 <= w[1].0), + "not disjoint: {r:?}" + ); + assert_eq!(map.key_idx_for(10), Some(0)); + assert_eq!(map.key_idx_for(22), Some(1)); + assert_eq!(map.key_idx_for(40), Some(2)); + assert_eq!(map.key_indices(), &[0, 1, 2]); } // The mapped descramble indexes the committed key pool POSITIONALLY, so the ORDER of the diff --git a/src/diag.rs b/src/diag.rs index c8333065..0a23264a 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -2,9 +2,11 @@ //! //! Emits one terse line per row (title, cell, stream, decision) under the `tracing` target //! `freemkv::diag`, routed to `log.txt` at `--log-level 3`. Every line is prefixed `tag=` -//! (`disc`, `title`, `dvd.cell`, `dvd.vattr`, `dvd.aattr`, `bd.clip`, `bd.mark`, `aacs`, -//! `stream`, `decision`) so a log scraper can filter, and raw bytes are shown as `0xNN` beside -//! their decode. This module only reads already-parsed scan state. +//! (`disc`, `title`, `bd.clip`, `extent`, `stream`, `decision`, `decision.demoted`, `aacs`, +//! `dvd.cell`, `dvd.pgc`, `dvd.chap`, `dvd.vobs`, `dvd.vattr`, `dvd.aattr`, `dvd.sattr`, +//! `dvd.astctl`, `dvd.spstctl`, `dvd.substream`, `dvd.aroute`, `mkv.track`, `mkv.opening.*`, +//! `mp2.channels`) so a log scraper can filter. Raw bytes are shown as `0xNN` beside their +//! decode. This module only reads already-parsed scan state. use crate::disc::{ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, Stream}; use crate::ifo::{CellCategory, DvdTitle}; @@ -298,7 +300,7 @@ fn codec_private_hex(cp: Option<&[u8]>) -> String { // (no I/O); `record` appends the returned bytes to the side file. fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Vec { let mut rec = Vec::with_capacity(14 + data.len()); - rec.push(track_idx as u8); + rec.push(u8::try_from(track_idx).unwrap_or(u8::MAX)); rec.push(keyframe as u8); rec.extend_from_slice(&pts_ns.to_le_bytes()); rec.extend_from_slice(&(data.len() as u32).to_le_bytes()); @@ -406,23 +408,26 @@ impl OpeningCapture { /// Record one coded frame for `track_idx` if that track is still under its /// per-track cap. Writes the framed raw bytes to the side file and logs a - /// one-line summary. A write error disables further capture for the track - /// (counter pinned to the cap) but never propagates — the rip is unaffected. + /// one-line summary. A write error disables further capture for all tracks + /// (counters pinned to the cap) but never propagates — the rip is unaffected. pub fn record(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) { let Some(count) = self.counts.get_mut(track_idx) else { return; }; - if *count >= OPENING_FRAMES_PER_TRACK { + // The record's track field is a u8; skip higher indices rather than alias them. + if *count >= OPENING_FRAMES_PER_TRACK || track_idx > usize::from(u8::MAX) { return; } use std::io::Write; let rec = frame_record(track_idx, pts_ns, keyframe, data); if let Err(e) = self.file.write_all(&rec) { - // Stop trying on this track; a broken side file must not stall mux. - *count = OPENING_FRAMES_PER_TRACK; + // A torn record desyncs a len-framed reader: stop capture on every track. + self.counts + .iter_mut() + .for_each(|c| *c = OPENING_FRAMES_PER_TRACK); tracing::debug!( target: DIAG, - "tag=mkv.opening.frame track={track_idx} write_failed={e} (capture stopped for track)", + "tag=mkv.opening.frame track={track_idx} write_failed={e} (capture stopped)", ); return; } @@ -562,7 +567,7 @@ fn dump_title(ti: usize, title: &DiscTitle) { for (ci, c) in title.clips.iter().enumerate() { tracing::debug!( target: DIAG, - "tag=clip title={ti} idx={ci} id={:?} in={} out={} dur={:.1}s src_packets={}", + "tag=bd.clip title={ti} idx={ci} id={:?} in={} out={} dur={:.1}s src_packets={}", c.clip_id, c.in_time, c.out_time, @@ -691,6 +696,71 @@ mod tests { ); } + // A track index that does not fit the u8 record field is skipped, not aliased. + #[test] + fn opening_capture_skips_track_indices_above_255() { + let path = std::env::temp_dir().join(format!("fmk-diag-hi-{}.bin", std::process::id())); + let file = std::fs::File::create(&path).unwrap(); + let mut cap = OpeningCapture { + file, + counts: vec![0; 300], + }; + cap.record(256, 0, true, b"x"); + let len = std::fs::metadata(&path).unwrap().len(); + let _ = std::fs::remove_file(&path); + assert_eq!(len, 0, "idx 256 must not write an aliased track-0 record"); + assert_eq!(cap.counts[256], 0); + } + + // A failed write may leave a torn record, so capture stops on every track. + #[test] + fn opening_capture_write_error_disables_all_tracks() { + let path = std::env::temp_dir().join(format!("fmk-diag-ro-{}.bin", std::process::id())); + std::fs::write(&path, b"").unwrap(); + let file = std::fs::File::open(&path).unwrap(); // read-only: writes fail + let mut cap = OpeningCapture { + file, + counts: vec![0; 2], + }; + cap.record(0, 0, true, b"x"); + let _ = std::fs::remove_file(&path); + assert_eq!( + cap.counts[1], OPENING_FRAMES_PER_TRACK, + "other tracks must stop after a write error" + ); + } + + // The per-track cap stops one track at OPENING_FRAMES_PER_TRACK records without + // affecting another, and an out-of-range track index is ignored. + #[test] + fn opening_capture_caps_each_track_and_ignores_unknown_tracks() { + let path = std::env::temp_dir().join(format!("fmk-diag-cap-{}.bin", std::process::id())); + let file = std::fs::File::create(&path).unwrap(); + let mut cap = OpeningCapture { + file, + counts: vec![0; 2], + }; + for _ in 0..OPENING_FRAMES_PER_TRACK + 5 { + cap.record(0, 0, true, b"x"); + } + cap.record(1, 0, false, b"y"); + cap.record(2, 0, true, b"z"); + let len = std::fs::metadata(&path).unwrap().len(); + let _ = std::fs::remove_file(&path); + assert_eq!(cap.counts, vec![OPENING_FRAMES_PER_TRACK, 1]); + assert_eq!(len as usize, (OPENING_FRAMES_PER_TRACK + 1) * 15); + } + + // With the diag target off, no side file is created and the rip is unaffected. + #[test] + fn opening_capture_new_is_none_when_diag_is_off() { + let path = std::env::temp_dir().join(format!("fmk-diag-off-{}.mkv", std::process::id())); + assert!(OpeningCapture::new(&path, 3).is_none()); + let mut side = path.as_os_str().to_os_string(); + side.push(".opening.bin"); + assert!(!std::path::Path::new(&side).exists()); + } + #[test] fn res_str_keeps_interlace_marker() { assert_eq!(res_str(Resolution::R576i), "576i"); diff --git a/src/dirimage/encode.rs b/src/dirimage/encode.rs index 7c2931a9..58d20d58 100644 --- a/src/dirimage/encode.rs +++ b/src/dirimage/encode.rs @@ -136,7 +136,7 @@ fn put_domain_id(buf: &mut [u8]) { /// OSTA CS0 d-string: a compression-ID byte, the characters, then the used /// length in the FIELD'S LAST byte (ECMA-167 1/7.2.12 + UDF 2.1.3). An /// all-zero field is the empty string. -fn put_dstring(buf: &mut [u8], s: &str) { +pub(super) fn put_dstring(buf: &mut [u8], s: &str) { if s.is_empty() { return; } @@ -406,7 +406,7 @@ fn file_entry( s[24..26].copy_from_slice(&1u16.to_le_bytes()); // max number of entries s[27] = if is_dir { 4 } else { 5 }; // file type: directory / byte sequence // s[28..34] parent ICB location: not recorded (permitted). - // s[34..36] ICB flags: 0 => short allocation descriptors. `udf.rs:601` + // s[34..36] ICB flags: 0 => short allocation descriptors. `udf::read_filesystem` // reads exactly this word to pick its AD stride. s[34..36].copy_from_slice(&0u16.to_le_bytes()); // UDF's sentinel for "not specified" is 0xFFFFFFFF, not 0 — 0 is a real @@ -653,7 +653,6 @@ mod tests { #[test] fn crc16_matches_the_ecma167_check_value() { assert_eq!(crc16(b"123456789"), 0x31C3); - assert_ne!(crc16(b"123456789"), 0x29B1, "not the 0xFFFF-seeded variant"); } /// ECMA-167 3/7.2.3: the checksum is the sum of the tag's first 16 bytes @@ -681,7 +680,7 @@ mod tests { } /// ASCII takes compression ID 8; anything above takes 16 (UTF-16BE), - /// because `parse_udf_name` decodes compression-8 bytes as UTF-8. + /// because `parse_udf_name` decodes compression-8 bytes as Latin-1. #[test] fn cs0_picks_the_encoding_the_parser_can_decode() { assert_eq!(encode_cs0("AB"), vec![8, b'A', b'B']); diff --git a/src/dirimage/layout.rs b/src/dirimage/layout.rs index 7574a6d4..a54f9a2f 100644 --- a/src/dirimage/layout.rs +++ b/src/dirimage/layout.rs @@ -198,7 +198,7 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< ); continue; } - names.push(as_read.to_ascii_uppercase()); + let key = as_read.to_ascii_uppercase(); if ft.is_dir() { // Link count (child dirs + 1) is stored in 16 bits; the global entry // cap alone permits a single directory holding more than that, which @@ -208,6 +208,7 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< path: disc_path.to_string(), }); } + names.push(key); dirs.push(walk(&entry.path(), &child_path, depth + 1, entries)?); } else { let meta = match std::fs::metadata(entry.path()) { @@ -230,6 +231,7 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< ); continue; } + names.push(key); files.push(FileNode { name, disc_path: child_path, @@ -243,6 +245,13 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< } } + let dir_len = dir_bytes(&dirs, &files); + if dir_len.div_ceil(SECTOR) > (crate::udf::MAX_DIR_BYTES as usize) / SECTOR { + // The UDF reader rejects a larger directory; refuse it here, naming it. + return Err(Error::DirImageFanout { + path: disc_path.to_string(), + }); + } // `find_dir`/`read_file` match path components case-insensitively, so two // entries differing only in case are indistinguishable to every consumer — // the second would silently shadow the first. Only on a case-sensitive host. @@ -334,14 +343,20 @@ fn be_u32(buf: &[u8], off: usize) -> Option { // Read the first `n` bytes of an IFO so its placement offsets can be resolved. Errors propagate // — an empty buffer would record NO placement constraint and the rip would read the wrong // sectors. -fn read_head(path: &Path, n: usize) -> Result> { +fn read_head(path: &Path, disc_path: &str, n: usize) -> Result> { use std::io::Read; let mut buf = vec![0u8; n]; // `read_exact`, not `read`: a single `read` may legally return fewer bytes on // a network/FUSE mount (a NAS-hosted backup is normal here). A short buffer // records NO constraint, so the rip reads the wrong sectors at exit 0. let mut f = std::fs::File::open(path).map_err(Error::from)?; - f.read_exact(&mut buf).map_err(Error::from)?; + f.read_exact(&mut buf).map_err(|e| match e.kind() { + // A short IFO cannot resolve placement; other errors stay retryable IoError. + std::io::ErrorKind::UnexpectedEof => Error::DirImagePlacement { + path: disc_path.to_string(), + }, + _ => Error::from(e), + })?; Ok(buf) } @@ -397,7 +412,7 @@ fn place_video_ts(vts: &mut DirNode, start: u32) -> Result { if let Some(c) = class && c.role == Role::Ifo { - let head = read_head(&vts.files[i].host, 0xC8)?; + let head = read_head(&vts.files[i].host, &vts.files[i].disc_path, 0xC8)?; let menu = be_u32(&head, 0xC0).unwrap_or(0); // One group per title set: `VTS_01_0.IFO` and `VTS_1_0.IFO` parse to // the same group, and a second insert would overwrite the first's @@ -507,9 +522,8 @@ fn count_nodes(dir: &DirNode, dirs: &mut u32, files: &mut u32) { } } -/// Total blocks the metadata region needs: File Set Descriptor, its -/// Terminating Descriptor, one File Entry per node, and each directory's FID -/// list. +/// Blocks for the File Entries and FID lists below `dir`; the FSD and +/// Terminating Descriptor are added by `metadata_block_count`. fn metadata_blocks(dir: &DirNode) -> u64 { let mut n = 1 + dir.files.len() as u64; n += dir_bytes(&dir.dirs, &dir.files).div_ceil(SECTOR) as u64; @@ -549,8 +563,10 @@ pub(super) fn plan(root: &Path) -> Result { // Metadata: block 0 is the FSD, block 1 its Terminating Descriptor. let mut next = 2u32; - let mut uid = 0u64; + // UDF 3.2.1.1 reserves Unique IDs 1-15: the root is 0, the rest start at 16. + let mut uid = 16u64; assign_metadata(&mut tree, 0, &mut next, &mut uid); + tree.unique_id = 0; tree.parent_icb_lba = tree.icb_lba; // root's parent FID points at itself let part_start = MIN_PART_START; @@ -608,10 +624,24 @@ pub(super) fn plan(root: &Path) -> Result { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "FREEMKV".to_string()); - // UDF volume identifiers are a 32-byte d-string: one compression byte, the - // characters, and a trailing length byte. Trim rather than let - // `put_dstring` cut a multi-byte character in half. - let volume_id: String = volume_id.chars().take(30).collect(); + // A 32-byte d-string holds 30 ASCII chars, or 15 UTF-16 units once non-ASCII + // forces CS0 ID 16. Round-trip through the encoder and the reader so `volume_id` + // is exactly what the reader decodes (it drops surrogates and trims). + let mut units = 0; + let cut: String = volume_id + .chars() + .take_while(|c| { + units += if volume_id.is_ascii() { + 1 + } else { + c.len_utf16() + }; + units <= if volume_id.is_ascii() { 30 } else { 15 } + }) + .collect(); + let mut field = [0u8; 32]; + super::encode::put_dstring(&mut field, &cut); + let volume_id = crate::udf::parse_dstring(&field); Ok(Layout { part_start, @@ -900,4 +930,26 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + + // Exactly MAX_DEPTH levels below the root is accepted; one more is refused. + #[test] + fn the_nesting_cap_accepts_exactly_max_depth() { + let mk = |tag: &str, levels: usize| { + let base = std::env::temp_dir().join(format!("fmkv-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + // BDMV is level 1, so the tree is a valid disc layout. + let mut deep = base.join("BDMV"); + for _ in 1..levels { + deep = deep.join("d"); + } + std::fs::create_dir_all(&deep).unwrap(); + base + }; + let ok = mk("depth-ok", MAX_DEPTH as usize); + assert!(plan(&ok).is_ok(), "MAX_DEPTH levels must be accepted"); + let over = mk("depth-over", MAX_DEPTH as usize + 1); + assert!(matches!(plan(&over), Err(Error::DirImageTooLarge))); + let _ = std::fs::remove_dir_all(&ok); + let _ = std::fs::remove_dir_all(&over); + } } diff --git a/src/dirimage/mod.rs b/src/dirimage/mod.rs index 6b12f063..27612076 100644 --- a/src/dirimage/mod.rs +++ b/src/dirimage/mod.rs @@ -183,8 +183,15 @@ impl DirImage { self.open.insert(0, entry); return Ok(&mut self.open[0].1); } - let f = File::open(&self.files[file].host).map_err(Error::from)?; - let md = f.metadata().map_err(Error::from)?; + // A vanished file is a change; name it. Other io errors stay retryable IoError. + let changed = |e: std::io::Error| match e.kind() { + std::io::ErrorKind::NotFound => Error::DirImageFileChanged { + path: self.files[file].disc_path.clone(), + }, + _ => Error::from(e), + }; + let f = File::open(&self.files[file].host).map_err(changed)?; + let md = f.metadata().map_err(changed)?; // Size AND mtime: size alone is content-blind, and VOB placement depends on // IFO bytes 0xC0/0xC4 — an IFO rewritten in place keeps its sector-aligned // length, so size alone would miss it. mtime only compared if both present. @@ -207,7 +214,7 @@ impl DirImage { // Fill `out` (whole sectors) from one data range starting at `lba`. `out` // is pre-zeroed, so a tail sector zero-pads — matching `file_extents`' - // div_ceil(2048) (udf.rs:816) that every consumer expects. + // div_ceil(2048) (`udf::file_extents`) that every consumer expects. fn fill(&mut self, r: &DataRange, lba: u32, out: &mut [u8]) -> Result<()> { let within = (lba - r.start_lba) as u64 * SECTOR as u64; let want = (r.bytes.saturating_sub(within)).min(out.len() as u64) as usize; diff --git a/src/dirimage/tests.rs b/src/dirimage/tests.rs index 736deced..87c2c4c8 100644 --- a/src/dirimage/tests.rs +++ b/src/dirimage/tests.rs @@ -347,6 +347,37 @@ fn a_file_that_shrinks_after_planning_fails_the_read() { assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED); } +/// Bump a file's mtime by a minute without changing its length. +fn touch_later(path: &std::path::Path) { + let f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); + let t = f.metadata().unwrap().modified().unwrap() + std::time::Duration::from_secs(60); + f.set_modified(t).unwrap(); +} + +/// An IFO rewritten in place at the SAME length still moves VOB placement, so a later +/// mtime must fail the read; a same-length, later-mtime VOB (size-only check) must not. +#[test] +fn an_ifo_rewritten_at_the_same_length_fails_but_a_touched_vob_does_not() { + let s = Scratch::new("ifo-mtime"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1)); + s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR)); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let ext = |img: &mut DirImage, p: &str| fs.file_extents(img, p).unwrap()[0]; + let (ifo_lba, _) = ext(&mut img, "/VIDEO_TS/VTS_01_0.IFO"); + let (vob_lba, _) = ext(&mut img, "/VIDEO_TS/VTS_01_1.VOB"); + let mut buf = vec![0u8; SECTOR]; + + touch_later(&s.path().join("VIDEO_TS/VTS_01_1.VOB")); + img.read_sectors(vob_lba, 1, &mut buf, false) + .expect("size-only files ignore mtime"); + + touch_later(&s.path().join("VIDEO_TS/VTS_01_0.IFO")); + let err = img.read_sectors(ifo_lba, 1, &mut buf, false).unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED); +} + // ── DVD placement ─────────────────────────────────────────────────────────── /// Build a `VTS_01_0.IFO` body whose VOB pointers are the given sector @@ -1121,3 +1152,353 @@ fn read_structure_files_propagates_halt() { res.map(|f| f.len()) ); } + +// ── Audit fixes: planning edge cases ──────────────────────────────────────── + +/// A symlink skipped from the image must not take part in the case-collision check. +#[cfg(unix)] +#[test] +fn a_skipped_symlink_does_not_collide_with_a_same_name_file() { + let (s, _, _) = bdmv_scratch(); + // The reader trims the leading space, so both names read back as `A.M2TS`. + s.file("BDMV/STREAM/A.M2TS", b"x"); + std::os::unix::fs::symlink("/nonexistent-target", s.path().join("BDMV/STREAM/ A.M2TS")) + .unwrap(); + DirImage::open(s.path()).expect("dangling symlink is omitted, not a collision"); +} + +/// A directory whose FID list exceeds the UDF reader's cap is refused at plan time, by name. +#[test] +fn an_over_wide_directory_is_refused_at_plan_time() { + let (s, _, _) = bdmv_scratch(); + for i in 0..24_000 { + s.file(&format!("BDMV/STREAM/{i:05}"), b""); + } + let err = DirImage::open(s.path()).unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FANOUT); + assert!(err.to_string().contains("/BDMV/STREAM"), "got {err}"); +} + +/// A truncated IFO is a typed error naming the file. +#[test] +fn a_truncated_ifo_names_the_file() { + let s = Scratch::new("dvdshort"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &[0u8; 8]); + let err = DirImage::open(s.path()).unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_PLACEMENT); + assert!(err.to_string().contains("VTS_01_0.IFO"), "got {err}"); +} + +/// A file that vanishes after planning names the file. +#[test] +fn a_file_removed_after_planning_names_the_file() { + let (s, _, _) = bdmv_scratch(); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let (lba, sectors) = fs + .file_extents(&mut img, "/BDMV/STREAM/00000.m2ts") + .unwrap()[0]; + std::fs::remove_file(s.path().join("BDMV/STREAM/00000.m2ts")).unwrap(); + let mut buf = vec![0u8; sectors as usize * SECTOR]; + let err = img + .read_sectors(lba, sectors as u16, &mut buf, false) + .unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED); + assert!(err.to_string().contains("00000.m2ts"), "got {err}"); +} + +/// UDF reserves Unique IDs 1-15; only the root may be below 16. +#[test] +fn synthesized_unique_ids_avoid_the_reserved_range() { + let (s, _, _) = bdmv_scratch(); + let plan = layout::plan(s.path()).unwrap(); + fn check(d: &layout::DirNode, is_root: bool) { + if is_root { + assert_eq!(d.unique_id, 0); + } else { + assert!(d.unique_id >= 16, "dir uid {}", d.unique_id); + } + for f in &d.files { + assert!(f.unique_id >= 16, "file uid {}", f.unique_id); + } + for s in &d.dirs { + check(s, false); + } + } + check(&plan.root, true); + assert!(plan.next_unique_id >= 16); +} + +/// A non-ASCII folder name must read back from the UDF exactly as `volume_id()` reports it. +#[test] +fn a_non_ascii_volume_id_matches_what_the_reader_sees() { + let base = Scratch::new("vol"); + let name = "Am\u{e9}lie (2001) Blu-ray \u{1F600}\u{1F600}"; + base.file(&format!("{name}/BDMV/index.bdmv"), &pattern(1, 16)); + base.file(&format!("{name}/BDMV/STREAM/00000.m2ts"), &pattern(2, 4096)); + let mut img = DirImage::open(&base.path().join(name)).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + assert_eq!(fs.volume_id, img.volume_id()); +} + +/// A folder name with an emoji and a trailing space INSIDE the 15-unit cut still matches the reader. +#[test] +fn a_volume_id_cut_inside_emoji_or_space_matches_the_reader() { + for (tag, name) in [ + ("volemoji", "Film \u{1F600} x"), + ("volspace", "Cr\u{e8}me br\u{fb}l\u{e9}e 1 x"), + ] { + let base = Scratch::new(tag); + base.file(&format!("{name}/BDMV/index.bdmv"), &pattern(1, 16)); + base.file(&format!("{name}/BDMV/STREAM/00000.m2ts"), &pattern(2, 4096)); + let mut img = DirImage::open(&base.path().join(name)).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + assert_eq!(fs.volume_id, img.volume_id(), "{name}"); + } +} + +/// An unreadable (not vanished) file keeps its IoError so engine retry logic still sees it. +#[cfg(unix)] +#[test] +fn an_unreadable_file_is_an_io_error_not_file_changed() { + use std::os::unix::fs::PermissionsExt; + let (s, _, _) = bdmv_scratch(); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let (lba, sectors) = fs + .file_extents(&mut img, "/BDMV/STREAM/00000.m2ts") + .unwrap()[0]; + let p = s.path().join("BDMV/STREAM/00000.m2ts"); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::File::open(&p).is_ok() { + return; // running as root: permissions not enforced + } + let mut buf = vec![0u8; sectors as usize * SECTOR]; + let err = img + .read_sectors(lba, sectors as u16, &mut buf, false) + .unwrap_err(); + assert!( + matches!(err, crate::error::Error::IoError { .. }), + "expected IoError, got {err:?}" + ); +} + +/// An unreadable IFO is an IoError; only a short one is a placement error. +#[cfg(unix)] +#[test] +fn an_unreadable_ifo_is_an_io_error_not_placement() { + use std::os::unix::fs::PermissionsExt; + let s = Scratch::new("dvdperm"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vec![0u8; SECTOR]); + let p = s.path().join("VIDEO_TS/VTS_01_0.IFO"); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::File::open(&p).is_ok() { + return; // running as root: permissions not enforced + } + let err = DirImage::open(s.path()).unwrap_err(); + assert!( + matches!(err, crate::error::Error::IoError { .. }), + "expected IoError, got {err:?}" + ); +} + +// Two IFOs that parse to the same title-set group (VTS_01_0 vs VTS_1_0) must be refused, or the +// second's constraint silently overwrites the first's. +#[test] +fn duplicate_group_ifos_are_refused() { + let s = Scratch::new("dvddup"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 0)); + s.file("VIDEO_TS/VTS_1_0.IFO", &vts_ifo(SECTOR, 0, 0)); + let err = DirImage::open(s.path()).expect_err("duplicate title-set IFOs"); + assert!( + matches!(err, crate::error::Error::DirNameCollision { .. }), + "expected DirNameCollision, got {err:?}" + ); +} + +// VIDEO_TS.IFO's 0xC0 points at the VMG menu VOB; it must land exactly there. +#[test] +fn vmg_menu_vob_lands_at_the_ifo_0xc0_offset() { + let s = Scratch::new("dvdvmg"); + let mut vmg = vec![0u8; SECTOR]; + vmg[0xC0..0xC4].copy_from_slice(&5u32.to_be_bytes()); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vmg); + s.file("VIDEO_TS/VIDEO_TS.VOB", &pattern(4, SECTOR)); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let ifo = fs + .file_start_lba(&mut img, "/VIDEO_TS/VIDEO_TS.IFO") + .unwrap(); + let vob = fs + .file_start_lba(&mut img, "/VIDEO_TS/VIDEO_TS.VOB") + .unwrap(); + assert_eq!(vob, ifo + 5, "VMG menu VOB must sit at IFO + 0xC0"); +} + +fn ifo_folder(tag: &str) -> Scratch { + let s = Scratch::new(tag); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1)); + s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR)); + s +} + +fn ifo_lba(img: &mut DirImage) -> u32 { + let fs = udf::read_filesystem(img).unwrap(); + fs.file_start_lba(img, "/VIDEO_TS/VTS_01_0.IFO").unwrap() +} + +// An IFO rewritten in place with the same size but a new mtime must be refused at read time. +#[test] +fn an_ifo_with_a_changed_mtime_is_refused_on_read() { + let s = ifo_folder("mtime"); + let mut img = DirImage::open(s.path()).unwrap(); + let lba = ifo_lba(&mut img); + let f = std::fs::OpenOptions::new() + .write(true) + .open(s.path().join("VIDEO_TS/VTS_01_0.IFO")) + .unwrap(); + let t = std::time::SystemTime::now() + std::time::Duration::from_secs(3600); + f.set_modified(t).unwrap(); + let mut buf = vec![0u8; SECTOR]; + let err = img.read_sectors(lba, 1, &mut buf, false).unwrap_err(); + assert!( + matches!(err, crate::error::Error::DirImageFileChanged { .. }), + "expected DirImageFileChanged, got {err:?}" + ); +} + +// A file that shrinks after its handle is open surfaces as DirImageFileChanged via fill's EOF arm. +#[test] +fn a_file_truncated_after_open_is_reported_as_changed() { + let s = ifo_folder("shrink"); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let vob = fs + .file_start_lba(&mut img, "/VIDEO_TS/VTS_01_1.VOB") + .unwrap(); + let mut buf = vec![0u8; SECTOR]; + img.read_sectors(vob, 1, &mut buf, false).unwrap(); + let f = std::fs::OpenOptions::new() + .write(true) + .open(s.path().join("VIDEO_TS/VTS_01_1.VOB")) + .unwrap(); + f.set_len(10).unwrap(); + let err = img.read_sectors(vob, 1, &mut buf, false).unwrap_err(); + assert!( + matches!(err, crate::error::Error::DirImageFileChanged { .. }), + "expected DirImageFileChanged, got {err:?}" + ); +} + +// Independently verify every assembled descriptor's tag (checksum, CRC, CRC length, location), +// including the FIDs of a directory large enough to span several blocks. +#[test] +fn every_assembled_descriptor_has_a_valid_tag() { + let s = Scratch::new("tags"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1)); + s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR)); + for i in 0..150 { + s.file(&format!("EXTRA/a_rather_long_file_name_{i:04}.bin"), b"x"); + } + let img = DirImage::open(s.path()).unwrap(); + + let check = |sec: &[u8], at: usize, len: usize, want_loc: u32, what: &str| { + let t = &sec[at..at + 16]; + let sum = t + .iter() + .enumerate() + .filter(|(i, _)| *i != 4) + .fold(0u8, |a, (_, b)| a.wrapping_add(*b)); + assert_eq!(t[4], sum, "{what}: checksum"); + let crc_len = u16::from_le_bytes([t[10], t[11]]) as usize; + assert_eq!(crc_len, len - 16, "{what}: CRC length"); + let crc = u16::from_le_bytes([t[8], t[9]]); + let mut c = 0u16; + for &b in &sec[at + 16..at + 16 + crc_len] { + c ^= (b as u16) << 8; + for _ in 0..8 { + c = if c & 0x8000 != 0 { + (c << 1) ^ 0x1021 + } else { + c << 1 + }; + } + } + assert_eq!(crc, c, "{what}: CRC"); + let loc = u32::from_le_bytes([t[12], t[13], t[14], t[15]]); + assert_eq!(loc, want_loc, "{what}: location"); + }; + + // The File Set Descriptor sits at partition block 0, which fixes the partition start. + let part_start = *img + .meta + .iter() + .find(|(_, s)| u16::from_le_bytes([s[0], s[1]]) == 256) + .expect("file set descriptor") + .0; + let mut dirs_seen = 0; + let mut multi_block_dir = false; + for (&lba, sec) in &img.meta { + let sec: &[u8] = &sec[..]; + let id = u16::from_le_bytes([sec[0], sec[1]]); + // Directory-data blocks can begin mid-descriptor; only descriptors are tagged here. + if lba > part_start && !matches!(id, 8 | 256 | 261) { + continue; + } + match id { + // The partition's own terminating descriptor is partition-relative. + 8 if lba > part_start => check(sec, 0, 512, lba - part_start, "partition terminator"), + 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 => { + // Length is recoverable from the tag itself; volume-space tags are absolute. + let len = u16::from_le_bytes([sec[10], sec[11]]) as usize + 16; + check(sec, 0, len, lba, &format!("VDS tag {id} @ {lba}")); + } + 256 => check(sec, 0, 512, lba - part_start, "file set"), + 261 => { + let l_ad = u32::from_le_bytes([sec[172], sec[173], sec[174], sec[175]]) as usize; + check(sec, 0, 176 + l_ad, lba - part_start, "file entry"); + if sec[27] != 4 { + continue; + } + dirs_seen += 1; + let len = + u32::from_le_bytes([sec[176], sec[177], sec[178], sec[179]]) & 0x3FFF_FFFF; + let blk = u32::from_le_bytes([sec[180], sec[181], sec[182], sec[183]]); + multi_block_dir |= len as usize > SECTOR; + let mut data = Vec::new(); + for b in 0..(len as usize).div_ceil(SECTOR) { + data.extend_from_slice(&img.meta[&(part_start + blk + b as u32)][..]); + } + let mut pos = 0usize; + while pos < len as usize { + let l_fi = data[pos + 19] as usize; + let l_iu = u16::from_le_bytes([data[pos + 36], data[pos + 37]]) as usize; + let unpadded = 38 + l_iu + l_fi; + assert_eq!(u16::from_le_bytes([data[pos], data[pos + 1]]), 257); + check( + &data, + pos, + unpadded, + blk + (pos / SECTOR) as u32, + &format!("FID @ dir byte {pos}"), + ); + pos += unpadded.div_ceil(4) * 4; + } + } + _ => {} + } + } + assert!( + dirs_seen >= 3, + "expected several directories, saw {dirs_seen}" + ); + assert!( + multi_block_dir, + "fixture must include a multi-block directory" + ); +} diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 85094866..22e6b2d1 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -10,6 +10,10 @@ use crate::udf; // `.fmts`, 3D uses `.ssif`. HD-DVD's `.evo` is a different tree and does NOT belong here. const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"]; +// MPLS/CLPI timestamps tick at 45 kHz; playlists shorter than this are menus/stubs. +const MPLS_TICKS_PER_SEC: f64 = 45000.0; +const MIN_TITLE_SECS: f64 = 30.0; + impl Disc { // Scan Blu-ray titles from MPLS playlists. `halt` is polled between playlists and once more // after the loop; a Halted read propagates instead of being swallowed. @@ -19,12 +23,18 @@ impl Disc { halt: Option<&crate::halt::Halt>, ) -> Result> { let mut titles = Vec::new(); + // clip_id -> packet count, shared across playlists so each .clpi is read once per scan. + let mut clip_pkts: std::collections::HashMap = + std::collections::HashMap::new(); if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") { for entry in &playlist_dir.entries { if halt.is_some_and(|h| h.is_cancelled()) { return Err(Error::Halted); } - if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") { + if !entry.is_dir + && entry.name.len() >= 5 + && entry.name.as_bytes()[entry.name.len() - 5..].eq_ignore_ascii_case(b".mpls") + { let path = format!("/BDMV/PLAYLIST/{}", entry.name); let mpls_data = match udf_fs.read_file(reader, &path) { Ok(data) => data, @@ -35,11 +45,22 @@ impl Disc { // Every other read failure keeps the pre-existing // best-effort skip: one unreadable playlist is not a // reason to abandon the disc. - Err(_) => continue, + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + playlist = ?entry.name, + "E{}", e.code() + ); + continue; + } }; - if let Some(title) = - Self::parse_playlist(reader, udf_fs, &entry.name, &mpls_data)? - { + if let Some(title) = Self::parse_playlist_memo( + reader, + udf_fs, + &entry.name, + &mpls_data, + &mut clip_pkts, + )? { titles.push(title); } } @@ -56,14 +77,34 @@ impl Disc { // Parse one MPLS playlist into a DiscTitle. `Ok(None)` covers both benign misses // (unparseable, sub-30s) and deliberate drops; `Err` (only Halted) means the scan is over. + #[cfg(test)] pub(super) fn parse_playlist( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, filename: &str, data: &[u8], ) -> Result> { - let Ok(parsed) = mpls::parse(data) else { - return Ok(None); + let mut clip_pkts = std::collections::HashMap::new(); + Self::parse_playlist_memo(reader, udf_fs, filename, data, &mut clip_pkts) + } + + // As `parse_playlist`, with a caller-owned clip_id -> packet-count memo. Failures are never + // inserted, so a transient .clpi error is retried by the next playlist. + pub(super) fn parse_playlist_memo( + reader: &mut dyn SectorSource, + udf_fs: &udf::UdfFs, + filename: &str, + data: &[u8], + clip_pkts: &mut std::collections::HashMap, + ) -> Result> { + let parsed = match mpls::parse(data) { + Ok(p) => p, + Err(e) => { + // Real code, matching the CLPI path: a malformed playlist is + // dropped, but never silently. + tracing::warn!(target: "freemkv::disc", playlist = ?filename, "E{}", e.code()); + return Ok(None); + } }; // Calculate duration from play items @@ -72,10 +113,10 @@ impl Disc { .iter() .map(|pi| (pi.out_time.saturating_sub(pi.in_time)) as u64) .sum(); - let duration_secs = duration_ticks as f64 / 45000.0; + let duration_secs = duration_ticks as f64 / MPLS_TICKS_PER_SEC; - // Skip very short playlists (< 30 seconds) - if duration_secs < 30.0 { + // Skip very short playlists + if duration_secs < MIN_TITLE_SECS { return Ok(None); } @@ -97,37 +138,46 @@ impl Disc { let mut feed_pos: u64 = 0; let mut spans: std::collections::HashMap = std::collections::HashMap::new(); + // `clip_pkts` memoizes the packet count per clip_id: neither a repeated PlayItem nor + // another playlist may re-read/re-parse a .clpi (hostile MPLS floods would never finish). for play_item in &parsed.play_items { - let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0; + let clip_dur = + play_item.out_time.saturating_sub(play_item.in_time) as f64 / MPLS_TICKS_PER_SEC; - let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id); - // A `.clpi` that cannot be read or parsed is NOT a benign miss: - // `duration_ticks` already claims the full runtime, so drop the - // title instead of silently shipping it missing bytes. - let clip_info = match udf_fs - .read_file(reader, &clpi_path) - .and_then(|clpi_data| clpi::parse(&clpi_data)) - { - Ok(info) => info, - // The operator's Stop, not a disc defect: every remaining command - // fails the same way once the drive's flag is set, so classifying - // it as unresolvable would truncate the title list at success. - Err(Error::Halted) => return Err(Error::Halted), - Err(e) => { - // The REAL code, not a fixed one: DiscRead, UdfNotFound and - // ClpiParse are different populations, and flattening them - // would send anyone triaging the first after the third. - tracing::warn!( - target: "freemkv::disc", - playlist = ?filename, - clip = ?play_item.clip_id, - "E{}", e.code() - ); - return Ok(None); - } + let pkt_count: u32 = if let Some(&n) = clip_pkts.get(&play_item.clip_id) { + n + } else { + let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id); + // A `.clpi` that cannot be read or parsed is NOT a benign miss: + // `duration_ticks` already claims the full runtime, so drop the + // title instead of silently shipping it missing bytes. + let clip_info = match udf_fs + .read_file(reader, &clpi_path) + .and_then(|clpi_data| clpi::parse(&clpi_data)) + { + Ok(info) => info, + // The operator's Stop, not a disc defect: every remaining command + // fails the same way once the drive's flag is set, so classifying + // it as unresolvable would truncate the title list at success. + Err(Error::Halted) => return Err(Error::Halted), + Err(e) => { + // The REAL code, not a fixed one: DiscRead, UdfNotFound and + // ClpiParse are different populations, and flattening them + // would send anyone triaging the first after the third. + tracing::warn!( + target: "freemkv::disc", + playlist = ?filename, + clip = ?play_item.clip_id, + "E{}", e.code() + ); + return Ok(None); + } + }; + let n = clip_info.source_packet_count; + clip_pkts.insert(play_item.clip_id.clone(), n); + n }; - let pkt_count: u32 = clip_info.source_packet_count; // The clip is marked seen only after its .clpi parses. That ordering // used to matter because a transient failure must not permanently @@ -140,7 +190,7 @@ impl Disc { // WHOLE-clip size/extents on purpose (no EP-map seek): out-of-mark // bytes are dropped downstream by PTS at the SeamPlan — see // `SeamPlan::place` (src/mux/timeline.rs) `raw_ns >= in_ns && <= out_ns`. - total_size += pkt_count as u64 * 192; + total_size += pkt_count as u64 * crate::consts::BD_SOURCE_PACKET_BYTES as u64; // Get stream file extents from UDF allocation descriptors (dual- // layer discs split files across layers). Normally `.m2ts`; AACS 2.1 @@ -211,24 +261,25 @@ impl Disc { "E{}", code ); } - _ => {} + // Every candidate was merely absent: the clip's bytes are gone, same + // as a missing .clpi, so refuse the title rather than ship it short. + (None, None) => { + tracing::warn!( + target: "freemkv::disc", + playlist = ?filename, + clip = ?play_item.clip_id, + "E{}", crate::error::E_UDF_NOT_FOUND + ); + return Ok(None); + } + (Some(_), None) => {} } // KNOWN GAP, deliberately left open: an empty-but-Ok `file_extents` // leaves the clip with no extents while size/timing still count it — // not closed since it's unclear that's always a defect (see hddvd.rs). if let Some(file_exts) = file_exts { let span_start = feed_pos; - for (lba, sectors) in file_exts { - if sectors > 0 && lba > 0 { - extents.push(Extent { - start_lba: lba, - sector_count: sectors, - }); - feed_pos = feed_pos.saturating_add( - sectors as u64 * crate::consts::SECTOR_BYTES as u64, - ); - } - } + push_extents(file_exts, &mut extents, &mut feed_pos); if feed_pos > span_start { spans.insert(play_item.clip_id.clone(), (span_start, feed_pos)); } @@ -323,6 +374,7 @@ impl Disc { // Stream type 4 = IG, unknown types -- skip. other => { tracing::warn!( + target: "freemkv::disc", "dropping STN stream entry: unhandled stream_type {} (PID {:#06x}, coding_type {:#04x})", other, s.pid, @@ -369,9 +421,9 @@ impl Disc { let pi = parsed.play_items.get(pi_idx)?; let preceding: f64 = parsed.play_items[..pi_idx] .iter() - .map(|p| p.out_time.saturating_sub(p.in_time) as f64 / 45000.0) + .map(|p| p.out_time.saturating_sub(p.in_time) as f64 / MPLS_TICKS_PER_SEC) .sum(); - let within = (m.timestamp as f64 - pi.in_time as f64) / 45000.0; + let within = (m.timestamp as f64 - pi.in_time as f64) / MPLS_TICKS_PER_SEC; let time_secs = preceding + within; Some(Chapter { time_secs: if time_secs < 0.0 { 0.0 } else { time_secs }, @@ -426,7 +478,11 @@ impl Disc { let xml_files: Vec<_> = dl_dir .entries .iter() - .filter(|e| !e.is_dir && e.name.to_lowercase().ends_with(".xml")) + .filter(|e| { + !e.is_dir + && e.name.len() >= 4 + && e.name.as_bytes()[e.name.len() - 4..].eq_ignore_ascii_case(b".xml") + }) .collect(); let eng = xml_files @@ -441,7 +497,7 @@ impl Disc { if let Some(start) = xml.find("") { let s = start + "".len(); if let Some(end) = xml[s..].find("") { - let title = xml[s..s + end].trim().to_string(); + let title = xml_text_decode(xml[s..s + end].trim()); if !title.is_empty() && title != "Blu-ray" { return Some(title); } @@ -455,6 +511,68 @@ impl Disc { } } +// Decode the five predefined XML entities and numeric char refs in element text, and unwrap a +// CDATA section; unknown or malformed references are kept literally. +/// Append each usable `(lba, sectors)` to `extents`, advancing `feed_pos` by its bytes. An extent +/// with no sectors or at LBA 0 occupies no readable bytes and is skipped without moving `feed_pos`. +fn push_extents(file_exts: Vec<(u32, u32)>, extents: &mut Vec, feed_pos: &mut u64) { + for (lba, sectors) in file_exts { + if sectors > 0 && lba > 0 { + extents.push(Extent { + start_lba: lba, + sector_count: sectors, + }); + *feed_pos = + feed_pos.saturating_add(sectors as u64 * crate::consts::SECTOR_BYTES as u64); + } + } +} + +fn xml_text_decode(raw: &str) -> String { + if let Some(inner) = raw + .strip_prefix("")) + { + return inner.trim().to_string(); + } + let mut out = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some(amp) = rest.find('&') { + out.push_str(&rest[..amp]); + rest = &rest[amp..]; + let decoded = rest.find(';').filter(|&semi| semi <= 10).and_then(|semi| { + let ent = &rest[1..semi]; + let ch = match ent { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => ent.strip_prefix('#').and_then(|n| { + match n.strip_prefix(['x', 'X']) { + Some(h) => u32::from_str_radix(h, 16).ok(), + None => n.parse::().ok(), + } + .and_then(char::from_u32) + }), + }; + ch.map(|c| (c, semi)) + }); + match decoded { + Some((c, semi)) => { + out.push(c); + rest = &rest[semi + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + #[cfg(test)] mod tests { use super::*; @@ -665,11 +783,16 @@ mod tests { let mut disc = MemDisc::new(); let udf = make_min_fs(&mut disc); let junk = vec![0u8; 100]; - assert!( - Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &junk) - .expect("scan") - .is_none() - ); + let (t, events) = crate::testlog::capture(|| { + Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &junk).expect("scan") + }); + assert!(t.is_none()); + let line = events + .iter() + .find(|e| e.target == "freemkv::disc") + .unwrap_or_else(|| panic!("a dropped playlist must be logged; got {events:?}")); + assert_eq!(line.level, tracing::Level::WARN); + assert_eq!(line.message(), format!("E{}", crate::error::E_MPLS_PARSE)); } /// Build the minimal fixture: an empty `BDMV/` directory (no STREAM, @@ -2336,6 +2459,48 @@ mod tests { ); } + /// Entities in are decoded by read_meta_title itself. + #[test] + fn read_meta_title_decodes_xml_entities() { + let mut disc = MemDisc::new(); + let xml = b"Tom & Jerry".to_vec(); + let dl = DirSpec { + name: "DL".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files: vec![file_with("bdmt_eng.xml", 104, 50000, xml, false)], + subdirs: vec![], + }; + let meta = DirSpec { + name: "META".to_string(), + icb_lba: 28, + dir_data_lba: 29, + files: Vec::new(), + subdirs: vec![dl], + }; + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![meta], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = udf::read_filesystem(&mut disc).expect("fs"); + assert_eq!( + Disc::read_meta_title(&mut disc, &udf), + Some("Tom & Jerry".to_string()) + ); + } + /// The placeholder title "Blu-ray" and empty titles are rejected /// (bluray.rs `!title.is_empty() && title != "Blu-ray"`). #[test] @@ -2438,4 +2603,427 @@ mod tests { let udf = make_min_fs(&mut disc); assert_eq!(Disc::read_meta_title(&mut disc, &udf), None); } + + #[test] + fn xml_text_decode_entities_and_cdata() { + assert_eq!(xml_text_decode("Tom & Jerry"), "Tom & Jerry"); + assert_eq!( + xml_text_decode("<A> AB &bogus; &"), + " AB &bogus; &" + ); + assert_eq!(xml_text_decode(""), "Tom & Jerry"); + } + + // A clip whose stream file is absent under every name must drop the title (like a + // missing .clpi), not keep it counting bytes that have no extents. + #[test] + fn parse_playlist_missing_stream_yields_no_title() { + // CLIPINF has the .clpi but STREAM has no stream file. + let mut disc = MemDisc::new(); + let udf = { + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "STREAM".to_string(), + icb_lba: 22, + dir_data_lba: 23, + files: Vec::new(), + subdirs: vec![], + }, + DirSpec { + name: "CLIPINF".to_string(), + icb_lba: 24, + dir_data_lba: 25, + files: vec![file_with("00001.clpi", 102, 8000, build_clpi(4000), false)], + subdirs: vec![], + }, + ], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + udf::read_filesystem(&mut disc).expect("fs") + }; + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("scan"); + assert!(t.is_none(), "missing stream file must drop the title"); + } + + struct CountingDisc { + inner: MemDisc, + reads: usize, + } + impl SectorSource for CountingDisc { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + self.reads += 1; + self.inner.read_sectors(lba, count, buf, recovery) + } + } + + // Repeated PlayItems of one clip must not re-read its .clpi. + #[test] + fn parse_playlist_repeated_clip_reads_clpi_once() { + let pi = |i: u32| PiSpec { + clip_id: *b"00001", + in_time: i * 60 * 45000, + out_time: (i + 1) * 60 * 45000, + }; + let mut reads = Vec::new(); + for n in [1u32, 6] { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 1000, 4000, 5000)]); + let mut cd = CountingDisc { + inner: disc, + reads: 0, + }; + let items: Vec = (0..n).map(pi).collect(); + let mpls = build_mpls(&items, (0, 0, 0, 0, 0, 0, 0, 0), &[], &[]); + Disc::parse_playlist(&mut cd, &udf, "00001.mpls", &mpls) + .expect("scan") + .expect("title"); + reads.push(cd.reads); + } + assert_eq!(reads[0], reads[1], "extra PlayItems of a seen clip re-read"); + } + + // Reads that touch one LBA (the .clpi of clip 00001 at 8000). + struct LbaWatch { + inner: MemDisc, + lba: u32, + hits: u32, + } + impl SectorSource for LbaWatch { + fn capacity_sectors(&self) -> u32 { + self.inner.capacity_sectors() + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + if lba <= self.lba && self.lba < lba + count as u32 { + self.hits += 1; + } + self.inner.read_sectors(lba, count, buf, recovery) + } + } + + // N playlists naming one clip must read its .clpi once per scan, not once per playlist. + #[test] + fn scan_bluray_titles_reads_shared_clpi_once_across_playlists() { + let mut disc = MemDisc::new(); + let udf = two_playlist_bd_fs(&mut disc); + let mut w = LbaWatch { + inner: disc, + lba: PART_START + 8000, + hits: 0, + }; + let titles = Disc::scan_bluray_titles(&mut w, &udf, None).expect("scan"); + assert_eq!(titles.len(), 2); + assert_eq!(w.hits, 1, "shared .clpi re-read per playlist"); + } + + // The in-loop poll must fire before any playlist is read (the post-loop poll alone would + // also yield Halted, so count reads). + #[test] + fn scan_bluray_titles_polls_halt_before_reading_each_playlist() { + let mut disc = MemDisc::new(); + let udf = two_playlist_bd_fs(&mut disc); + let mut cd = CountingDisc { + inner: disc, + reads: 0, + }; + let halt = crate::halt::Halt::new(); + halt.cancel(); + let res = Disc::scan_bluray_titles(&mut cd, &udf, Some(&halt)); + assert!(matches!(res, Err(Error::Halted)), "got {res:?}"); + assert_eq!(cd.reads, 0, "a cancelled scan must not read any playlist"); + } + + // Each clip's feed_span is its byte range in the concatenated extents; a repeated clip + // reuses its first span and adds no bytes. + #[test] + fn parse_playlist_feed_spans_follow_extent_order_and_repeat() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs( + &mut disc, + &[("00001", 1000, 4000, 5000), ("00002", 500, 2000, 9000)], + ); + let pi = |c: &[u8; 5], i: u32| PiSpec { + clip_id: *c, + in_time: i * 60 * 45000, + out_time: (i + 1) * 60 * 45000, + }; + let mpls = build_mpls( + &[pi(b"00001", 0), pi(b"00002", 1), pi(b"00001", 2)], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls) + .expect("scan") + .expect("title"); + assert_eq!(t.extents.len(), 2); + let s1 = 1000u64 * 2048; + let s2 = s1 + 500 * 2048; + assert_eq!(t.clips[0].feed_span, Some((0, s1))); + assert_eq!(t.clips[1].feed_span, Some((s1, s2))); + assert_eq!(t.clips[2].feed_span, Some((0, s1)), "repeat reuses span"); + } + + // A clip with no extents gets no feed_span and does not shift later clips' spans. (UDF + // itself yields no entry for a zero-length file, so this is the empty-span guard, not the + // extent filter; the filter is covered by `push_extents_*` below.) + #[test] + fn parse_playlist_clip_without_extents_gets_no_feed_span() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs( + &mut disc, + &[("00001", 0, 4000, 5000), ("00002", 500, 2000, 9000)], + ); + let pi = |c: &[u8; 5], i: u32| PiSpec { + clip_id: *c, + in_time: i * 60 * 45000, + out_time: (i + 1) * 60 * 45000, + }; + let mpls = build_mpls( + &[pi(b"00001", 0), pi(b"00002", 1)], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls) + .expect("scan") + .expect("title"); + assert_eq!(t.extents.len(), 1); + assert_eq!(t.clips[0].feed_span, None); + assert_eq!(t.clips[1].feed_span, Some((0, 500 * 2048))); + } + + // The extent filter: zero-sector and LBA-0 extents are dropped and do not advance feed_pos. + // Each half is pinned separately (a lone `sectors > 0` or lone `lba > 0` must fail). + #[test] + fn push_extents_drops_empty_and_lba_zero_extents_without_advancing() { + let mut extents = Vec::new(); + let mut pos = 0u64; + push_extents( + vec![(100, 2), (200, 0), (0, 5), (300, 3)], + &mut extents, + &mut pos, + ); + let got: Vec<(u32, u32)> = extents + .iter() + .map(|e| (e.start_lba, e.sector_count)) + .collect(); + assert_eq!(got, vec![(100, 2), (300, 3)]); + assert_eq!(pos, 5 * 2048); + } + + #[test] + fn push_extents_drops_zero_sector_extent_alone() { + let mut extents = Vec::new(); + let mut pos = 0u64; + push_extents(vec![(200, 0), (300, 1)], &mut extents, &mut pos); + assert_eq!(extents.len(), 1); + assert_eq!(pos, 2048); + } + + #[test] + fn push_extents_drops_lba_zero_extent_alone() { + let mut extents = Vec::new(); + let mut pos = 0u64; + push_extents(vec![(0, 4), (300, 1)], &mut extents, &mut pos); + assert_eq!(extents.len(), 1); + assert_eq!(pos, 2048); + } + + // A clip whose file has several allocation descriptors: its extents are all kept in + // order, its feed_span covers their sum, and the next clip starts after it. A zero-length + // AD ends the list (ECMA-167 4/12), so a descriptor after it never appears. + #[test] + fn parse_playlist_multi_extent_clip_feed_span_covers_sum() { + let mut disc = MemDisc::new(); + let stream = vec![ + file_ads( + "00001.m2ts", + 100, + &[ + (5000, 300 * 2048), + (7000, 200 * 2048), + (0, 0), + (8000, 99 * 2048), + ], + true, + ), + file("00002.m2ts", 101, 9000, 500 * 2048, true), + ]; + let clipinf = vec![ + file_with("00001.clpi", 102, 6000, build_clpi(4000), false), + file_with("00002.clpi", 103, 6100, build_clpi(2000), false), + ]; + let mk = |name: &str, icb, dd, files, subdirs| DirSpec { + name: name.to_string(), + icb_lba: icb, + dir_data_lba: dd, + files, + subdirs, + }; + let root = mk( + "", + 10, + 11, + Vec::new(), + vec![mk( + "BDMV", + 20, + 21, + Vec::new(), + vec![ + mk("STREAM", 22, 23, stream, vec![]), + mk("CLIPINF", 24, 25, clipinf, vec![]), + ], + )], + ); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = udf::read_filesystem(&mut disc).expect("fs"); + let pi = |c: &[u8; 5], i: u32| PiSpec { + clip_id: *c, + in_time: i * 60 * 45000, + out_time: (i + 1) * 60 * 45000, + }; + let mpls = build_mpls( + &[pi(b"00001", 0), pi(b"00002", 1)], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls) + .expect("scan") + .expect("title"); + let got: Vec<(u32, u32)> = t + .extents + .iter() + .map(|e| (e.start_lba, e.sector_count)) + .collect(); + let ps = udf.partition_start(); + assert_eq!( + got, + vec![(ps + 5000, 300), (ps + 7000, 200), (ps + 9000, 500)] + ); + let s1 = 500u64 * 2048; + assert_eq!(t.clips[0].feed_span, Some((0, s1))); + assert_eq!(t.clips[1].feed_span, Some((s1, s1 + 500 * 2048))); + } + + // Chapter names number the surviving entry marks 1..n, not their original mark index. + #[test] + fn parse_playlist_chapter_names_are_dense_ordinals_after_filter() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let mk = |mark_type: u8, secs: u32| MarkSpec { + mark_type, + play_item_ref: 0, + timestamp: secs * 45000, + }; + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 120 * 45000, + }], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[mk(2, 0), mk(1, 10), mk(2, 20), mk(1, 30), mk(1, 40)], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls) + .expect("scan") + .expect("title"); + let names: Vec<&str> = t.chapters.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["1", "2", "3"]); + } + + // With both bdmt_eng.xml and another language present, English wins regardless of order; + // with no English file the first XML is used. + #[test] + fn read_meta_title_prefers_english_then_falls_back_to_first() { + let title_of = |names: &[&str]| { + let mut disc = MemDisc::new(); + let files = names + .iter() + .enumerate() + .map(|(i, n)| { + let xml = format!("T-{n}").into_bytes(); + file_with(n, 104 + i as u32, 50000 + i as u32 * 10, xml, false) + }) + .collect(); + let dl = DirSpec { + name: "DL".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files, + subdirs: vec![], + }; + let meta = DirSpec { + name: "META".to_string(), + icb_lba: 28, + dir_data_lba: 29, + files: Vec::new(), + subdirs: vec![dl], + }; + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![meta], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = udf::read_filesystem(&mut disc).expect("fs"); + Disc::read_meta_title(&mut disc, &udf) + }; + assert_eq!( + title_of(&["bdmt_fra.xml", "bdmt_eng.xml"]).as_deref(), + Some("T-bdmt_eng.xml") + ); + assert_eq!( + title_of(&["bdmt_fra.xml", "bdmt_deu.xml"]).as_deref(), + Some("T-bdmt_fra.xml") + ); + } } diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 636c5c88..8e523415 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -5,6 +5,17 @@ use crate::ifo; use crate::sector::SectorSource; use crate::udf; +// Reads and parses a VMG copy (VIDEO_TS.IFO or .BUP), returning its bytes for dvdnav reuse. +fn load_vmg( + reader: &mut dyn SectorSource, + udf_fs: &udf::UdfFs, + path: &str, +) -> Result<(Vec, ifo::DvdInfo)> { + let bytes = udf_fs.read_file(reader, path)?; + let info = ifo::parse_vmg_with(reader, udf_fs, Some(&bytes))?; + Ok((bytes, info)) +} + impl Disc { // Scan DVD titles; a cancelled read is Err(Error::Halted), never an empty list. Also // returns the nav-resolved main feature, or None. @@ -19,15 +30,24 @@ impl Disc { // Read VIDEO_TS.IFO once and reuse for both the VMG parse and the nav // resolver below; otherwise the VTS reads in parse_vmg evict the single // sector-cache window, forcing a duplicate physical read of the same file. - let vmg_bytes = match udf_fs.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO") { - Ok(bytes) => bytes, + let (vmg_bytes, dvd_info) = match load_vmg(reader, udf_fs, "/VIDEO_TS/VIDEO_TS.IFO") { + Ok(v) => v, Err(Error::Halted) => return Err(Error::Halted), - Err(_) => return Ok((Vec::new(), None)), - }; - let dvd_info = match ifo::parse_vmg_with(reader, udf_fs, Some(&vmg_bytes)) { - Ok(info) => info, - Err(Error::Halted) => return Err(Error::Halted), - Err(_) => return Ok((Vec::new(), None)), + Err(ifo_err) => match load_vmg(reader, udf_fs, "/VIDEO_TS/VIDEO_TS.BUP") { + Ok(v) => { + tracing::warn!(target: "freemkv::scan", code = ifo_err.code(), "dvd: VIDEO_TS.IFO bad; using BUP"); + v + } + Err(Error::Halted) => return Err(Error::Halted), + Err(bup_err) => { + // Neither copy usable: nothing to enumerate (a missing pair is not a fault). + let missing = |e: &Error| matches!(e, Error::UdfNotFound { .. }); + if !(missing(&ifo_err) && missing(&bup_err)) { + tracing::warn!(target: "freemkv::scan", ifo = ifo_err.code(), bup = bup_err.code(), "dvd: VIDEO_TS.IFO and BUP unusable"); + } + return Ok((Vec::new(), None)); + } + }, }; // Follow the disc's First-Play navigation like a player would, to find the @@ -197,15 +217,20 @@ impl Disc { streams.extend(title_audio_streams(ts, dvd_title, title_number)); streams.extend(subtitle_streams); - // Chapter times are absolute from the PGC start. When leading cells are - // dropped, the muxed video shifts earlier by their total duration, so shift - // chapter marks too (clamping any that fell inside the dropped head to 0). - let chapters: Vec = dvd_title + // Chapter times are absolute from the PGC start: shift them by the dropped + // head's duration; marks inside the head collapse to one at 0.0. + let shifted: Vec = dvd_title .chapter_times .iter() + .map(|&t| (t - dropped_secs).max(0.0)) + .collect(); + let in_head = shifted.iter().filter(|&&t| t <= 0.0).count(); + let chapters: Vec = shifted + .into_iter() + .skip(in_head.saturating_sub(1)) .enumerate() - .map(|(i, &t)| Chapter { - time_secs: (t - dropped_secs).max(0.0), + .map(|(i, time_secs)| Chapter { + time_secs, name: chapter_name(i), }) .collect(); @@ -213,7 +238,7 @@ impl Disc { titles.push(DiscTitle { playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number), playlist_id: title_number, - duration_secs: dvd_title.duration_secs, + duration_secs: (dvd_title.duration_secs - dropped_secs).max(0.0), size_bytes, clips: Vec::new(), streams, @@ -1962,4 +1987,93 @@ mod tests { .collect(); assert_eq!(audio_pids, vec![0x00C0u16, 0x00C1u16]); } + + fn scan_cells(cells: &[(u32, u32, u8, u8)], programs: &[u8], nchap: u16) -> DiscTitle { + let mut disc = MemDisc::new(); + let vmg = build_vmg(&[(nchap, 1, 1)]); + let vts = build_vts_cells(1000, 0x00, cells, programs); + let udf = build_video_ts_fs( + &mut disc, + &[ + FileSpec { + name: "VIDEO_TS.IFO".into(), + icb_lba: 60, + data_lba: 5000, + contents: vmg, + }, + FileSpec { + name: "VTS_01_0.IFO".into(), + icb_lba: 62, + data_lba: 6000, + contents: vts, + }, + ], + ); + Disc::scan_dvd_titles(&mut disc, &udf, None) + .expect("scan") + .0 + .remove(0) + } + + // Dropped head time comes off the duration, and head chapters collapse to one mark at 0. + #[test] + fn scan_dvd_titles_dropped_head_adjusts_duration_and_chapters() { + let t = scan_cells( + &[ + (0, 9, 0x90, 0x05), + (10, 19, 0x90, 0x05), + (100, 199, 0x00, 0x20), + (300, 399, 0x00, 0x20), + ], + &[1, 2, 3, 4], + 4, + ); + assert!( + (t.duration_secs - 40.0).abs() < 0.01, + "got {}", + t.duration_secs + ); + let times: Vec = t.chapters.iter().map(|c| c.time_secs).collect(); + assert_eq!(times.iter().filter(|&&x| x <= 0.0).count(), 1, "{times:?}"); + assert_eq!(t.chapters[0].name, "1"); + } + + // VIDEO_TS / VTS IFOs are garbage or real, BUPs are garbage or real. + fn scan_ifo_bup(ifo_ok: bool, bup_ok: bool) -> Result> { + let mut disc = MemDisc::new(); + let vmg = build_vmg(&[(1, 1, 1)]); + let vts = build_vts_cells(1000, 0x00, &[(0, 99, 0x00, 0x10)], &[1]); + let pick = |ok: bool, good: &Vec| if ok { good.clone() } else { vec![0x55; 4096] }; + let spec = |name: &str, icb_lba, data_lba, contents| FileSpec { + name: name.into(), + icb_lba, + data_lba, + contents, + }; + let udf = build_video_ts_fs( + &mut disc, + &[ + spec("VIDEO_TS.IFO", 60, 5000, pick(ifo_ok, &vmg)), + spec("VTS_01_0.IFO", 62, 6000, pick(ifo_ok, &vts)), + spec("VIDEO_TS.BUP", 64, 7000, pick(bup_ok, &vmg)), + spec("VTS_01_0.BUP", 66, 8000, pick(bup_ok, &vts)), + ], + ); + Disc::scan_dvd_titles(&mut disc, &udf, None).map(|r| r.0) + } + + // A bad IFO falls back to its BUP; VOBs stay anchored at the IFO's own LBA. + #[test] + fn scan_dvd_titles_bad_ifo_uses_bup() { + let good = scan_ifo_bup(true, false).expect("good ifo"); + let bup = scan_ifo_bup(false, true).expect("bup"); + assert_eq!(bup.len(), 1); + assert_eq!(bup[0].extents[0].start_lba, good[0].extents[0].start_lba); + } + + // IFO and BUP both bad: no titles, not a scan error (base behaviour). + #[test] + fn scan_dvd_titles_bad_ifo_and_bup_is_empty() { + assert_eq!(scan_ifo_bup(false, false).expect("no error").len(), 0); + } } diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index e13214c7..46223fb8 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -922,11 +922,10 @@ mod tests { assert_eq!(st.mkb_version, None); } - /// A supplied handshake's volume_id and read_data_key propagate onto the - /// AacsState (encrypt.rs `handshake.map(|h| h.volume_id)` / - /// `handshake.and_then(|h| h.read_data_key)`). + /// A supplied handshake's volume_id propagates onto the AacsState + /// (encrypt.rs `handshake.map(|h| h.volume_id)`). #[test] - fn vid_only_propagates_handshake_vid_and_rdk() { + fn vid_only_propagates_handshake_vid() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, diff --git a/src/disc/extract.rs b/src/disc/extract.rs index ca6ad97b..b267a403 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -16,14 +16,21 @@ use std::io::Write; use std::path::{Path, PathBuf}; use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64}; -/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in -/// multiples of this so the decrypt step always sees whole units. -const AACS_UNIT_SECTORS: u32 = 3; +/// Content reads are issued in multiples of the AACS aligned unit (3 sectors) so the +/// decrypt step always sees whole units. +const AACS_UNIT_SECTORS: u32 = crate::aacs::content::ALIGNED_UNIT_SECTORS; /// Read batch in sectors for content streaming (a throughput knob, not a /// correctness one). A multiple of 3 so AACS units stay whole. const READ_BATCH_SECTORS: u32 = 1536; // 3 MiB, multiple of 3 /// Bounded per-extent retries on a read that fails before a recorded hole. const READ_RETRIES: u32 = 3; +/// Failed unit reads (one attempt each) per batch after which the rest is zero-filled unread. +const MAX_FAILED_UNIT_READS: u32 = 4; +/// Drive error granularity: a BD ECC cluster (32 sectors), also a whole number of DVD +/// ECC blocks (16). After a bad unit, reading resumes past this boundary. +const ECC_BLOCK_SECTORS: u64 = 32; +/// Sectors per read while cracking a VTS title key. +const CRACK_BATCH_SECTORS: u16 = 64; /// Attempts to delete the case-probe marker before giving up. const PROBE_REMOVE_ATTEMPTS: u32 = 3; @@ -342,7 +349,7 @@ impl Disc { // PLAYBACK ORDER — do NOT sort (the 1.5.1 bug: see `decrypt_keys_for_title`). // Crack the raw inner reader, NOT the decrypting view. A cancelled crack // (token or drive) is `Halted`, never a cached verdict. - match crate::css::crack_key_outcome(dec.inner_mut(), &extents, 64, halt) { + match crate::css::crack_key_outcome(dec.inner_mut(), &extents, CRACK_BATCH_SECTORS, halt) { crate::css::CrackOutcome::Cracked(state) => Ok(DecryptKeys::Css { title_key: state.title_key, }), @@ -490,7 +497,7 @@ fn plan_tree( { continue; } - let safe = sanitize_component(&entry.name)?; + let safe = sanitize_component(&entry.name); let child_rel = host_rel.join(&safe); let child_disc = format!("{disc_path}/{}", entry.name); // Collision: two distinct disc paths → one host FILE. Case-insensitive folds @@ -502,9 +509,7 @@ fn plan_tree( } else { key.to_string_lossy().into_owned() }; - if let Some(prev) = seen_hosts.insert(folded, child_disc.clone()) - && prev != child_disc - { + if seen_hosts.insert(folded, child_disc.clone()).is_some() { return Err(Error::DirNameCollision { host: key.to_string_lossy().into_owned(), }); @@ -599,13 +604,10 @@ fn extract_one_file( let final_path = dest.join(&pf.host_rel); let partial_path = with_partial_suffix(&final_path); - let file = - crate::io::WritebackFile::create_with_size_hint(&partial_path, pf.size).map_err(|e| { - Error::DirWriteFailed { - errno: e.raw_os_error(), - } + let mut writer = crate::io::WritebackFile::create_with_size_hint(&partial_path, pf.size) + .map_err(|e| Error::DirWriteFailed { + errno: e.raw_os_error(), })?; - let mut writer = file; let mut fr = FileResult { path: pf.host_rel.clone(), @@ -620,6 +622,10 @@ fn extract_one_file( let n = (pf.size as usize).min(bytes.len()); write_all(&mut writer, &bytes[..n], &partial_path)?; fr.bytes_good = n as u64; + // Data shorter than the declared size: the padded tail is lost, not good. + let gap = pf.size - n as u64; + fr.bytes_unreadable = gap; + *done_unreadable = done_unreadable.saturating_add(gap); finalize_file(writer, &partial_path, pf.size, &final_path)?; fr.complete = true; *done_bytes = done_bytes.saturating_add(pf.size); @@ -679,38 +685,35 @@ fn extract_one_file( let want = batch as usize * SECTOR_BYTES; let start = abs_lba.checked_add(sector_off); let blanked_before = dec.blanked_units(); - let read_ok = match start.filter(|l| l.checked_add(batch - 1).is_some()) { + // Bytes of this batch that could not be read (zero-filled below). + let lost: u64; + match start.filter(|l| l.checked_add(batch - 1).is_some()) { // Crafted extent past u32::MAX: no such sector — a hole, not a wrapped read. - None => false, - Some(lba) => match read_batch(dec, lba, batch, &mut buf[..want]) { - Ok(ok) => ok, + None => lost = want as u64, + Some(lba) => match read_batch_narrowed(dec, lba, batch, &mut buf[..want]) { + Ok(l) => lost = l, // Drive-level Stop: leave the `.partial`, same as an opts halt. Err(Error::Halted) => return Ok((fr, true)), Err(e) => return Err(e), }, - }; + } let chunk_bytes = want as u64; // Clip the chunk to the remaining file size on the final extent. let remaining = pf.size.saturating_sub(written); let usable = chunk_bytes.min(remaining) as usize; - if read_ok { + { + // Unreadable ranges were zero-filled by `read_batch_narrowed`; record the + // holes and keep going (no abort, no sweep-skip). + let lost = lost.min(usable as u64); write_all(&mut writer, &buf[..usable], &partial_path)?; // Damaged AACS units the reader blanked are unreadable, not good bytes. let unit = crate::aacs::content::ALIGNED_UNIT_LEN as u64; let blanked = (dec.blanked_units() - blanked_before) * unit; - let blanked = blanked.min(usable as u64); - fr.bytes_good = fr.bytes_good.saturating_add(usable as u64 - blanked); - fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(blanked); - *done_unreadable = done_unreadable.saturating_add(blanked); - } else { - // Bad sector(s): zero-fill this byte range, record the hole, - // keep going (no abort, no sweep-skip). - for b in buf[..usable].iter_mut() { - *b = 0; - } - write_all(&mut writer, &buf[..usable], &partial_path)?; - fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(usable as u64); - *done_unreadable = done_unreadable.saturating_add(usable as u64); + let blanked = blanked.min(usable as u64 - lost); + let bad = lost + blanked; + fr.bytes_good = fr.bytes_good.saturating_add(usable as u64 - bad); + fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(bad); + *done_unreadable = done_unreadable.saturating_add(bad); } written = written.saturating_add(usable as u64); *done_bytes = done_bytes.saturating_add(usable as u64); @@ -726,9 +729,14 @@ fn extract_one_file( } } - // Pad with a zero hole if the extents under-covered the declared size - // (sparse / allocated-not-recorded). The size hint already set the file - // length target; explicit truncate guarantees it. + // Extents that under-cover the declared size leave a zero-padded tail: count it lost + // so the file is not reported clean. + if written < pf.size { + let gap = pf.size - written; + fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(gap); + *done_unreadable = done_unreadable.saturating_add(gap); + *done_bytes = done_bytes.saturating_add(gap); + } finalize_file(writer, &partial_path, pf.size, &final_path)?; fr.complete = true; Ok((fr, false)) @@ -744,6 +752,65 @@ fn whole_unit_batch(remaining: u32) -> u32 { batch } +// Reads a batch (one retry); if it fails, re-reads it one AACS unit at a time, once each. A +// media-bad unit zero-fills, unread, up to the first unit at/after its ECC block's end; past +// MAX_FAILED_UNIT_READS the rest is zero-filled; an undecryptable unit loses only itself. Returns zero-filled bytes; Err = stop/key set. +fn read_batch_narrowed( + dec: &mut DecryptingSectorSource, + lba: u32, + count: u32, + buf: &mut [u8], +) -> Result { + if count <= AACS_UNIT_SECTORS { + if read_batch(dec, lba, count, buf)? { + return Ok(0); + } + buf.fill(0); + return Ok(buf.len() as u64); + } + if read_tries(dec, lba, count, buf, 1)? == Tried::Good { + return Ok(0); + } + let mut lost = 0u64; + let mut off = 0u32; + let mut failed = 0u32; + while off < count { + let n = AACS_UNIT_SECTORS.min(count - off); + let unit = &mut buf[off as usize * SECTOR_BYTES..(off + n) as usize * SECTOR_BYTES]; + let tried = read_tries(dec, lba + off, n, unit, 0)?; + if tried == Tried::Good { + off += n; + continue; + } + failed += 1; + if tried == Tried::Undecryptable { + unit.fill(0); + lost += unit.len() as u64; + off += n; + if failed >= MAX_FAILED_UNIT_READS { + buf[off as usize * SECTOR_BYTES..count as usize * SECTOR_BYTES].fill(0); + lost += u64::from(count - off) * SECTOR_BYTES as u64; + off = count; + } + continue; + } + // End of the ECC block holding the unit's last sector (a straddled boundary skips + // the later block), rounded up to a unit start; the rest when over budget. + let end = u64::from(lba) + u64::from(off + n); + let gap = (end.div_ceil(ECC_BLOCK_SECTORS) * ECC_BLOCK_SECTORS - end) as u32; + let next = if failed >= MAX_FAILED_UNIT_READS { + count + } else { + (off + n + gap.div_ceil(AACS_UNIT_SECTORS) * AACS_UNIT_SECTORS).min(count) + }; + let hole = &mut buf[off as usize * SECTOR_BYTES..next as usize * SECTOR_BYTES]; + hole.fill(0); + lost += hole.len() as u64; + off = next; + } + Ok(lost) +} + // One batch with bounded retries; Ok(false) = hole. Short reads retry; DecryptFailed // holes at once (never succeeds). The only Err is `Halted` (a user Stop). fn read_batch( @@ -752,20 +819,41 @@ fn read_batch( count: u32, buf: &mut [u8], ) -> Result { - for attempt in 0..=READ_RETRIES { + Ok(read_tries(dec, lba, count, buf, READ_RETRIES)? == Tried::Good) +} + +#[derive(PartialEq)] +enum Tried { + Good, + // Media/read failure: the drive lost the whole ECC block. + Media, + // Read fine but the unit cannot be decrypted; the next unit may still be fine. + Undecryptable, +} + +fn read_tries( + dec: &mut DecryptingSectorSource, + lba: u32, + count: u32, + buf: &mut [u8], + retries: u32, +) -> Result { + let mut attempt = 0; + loop { + let last = attempt >= retries; match dec.read_sectors(lba, count as u16, buf, true) { - Ok(n) if n >= buf.len() => return Ok(true), - Ok(_) if attempt < READ_RETRIES => continue, - Ok(_) => return Ok(false), + Ok(n) if n >= buf.len() => return Ok(Tried::Good), + Ok(_) if last => return Ok(Tried::Media), + Ok(_) => {} Err(Error::Halted) => return Err(Error::Halted), // The key set's loud stop (KU §2.4): never a hole, the run stops `.partial`. Err(e @ (Error::WholeDiscKeyMissing | Error::NoDiscKey { .. })) => return Err(e), - Err(Error::DecryptFailed) => return Ok(false), - Err(_) if attempt < READ_RETRIES => continue, - Err(_) => return Ok(false), + Err(Error::DecryptFailed) => return Ok(Tried::Undecryptable), + Err(_) if last => return Ok(Tried::Media), + Err(_) => {} } + attempt += 1; } - Ok(false) } fn write_all(writer: &mut crate::io::WritebackFile, data: &[u8], path: &Path) -> Result<()> { @@ -925,65 +1013,50 @@ fn dir_is_non_empty(dir: &Path) -> bool { .unwrap_or(false) } -// Sanitizes ONE disc-path component: rejects `..`, host-illegal chars, control bytes; strips -// trailing dot/space (Windows); substitutes (not rejects) a Windows reserved device name. -fn sanitize_component(name: &str) -> Result { - if name == ".." || name == "." { - return Err(Error::DirNameCollision { - host: name.to_string(), - }); - } - let mut out = String::with_capacity(name.len()); - for ch in name.chars() { - match ch { - '\0' | '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' => { - return Err(Error::DirNameCollision { - host: name.to_string(), - }); - } - c if (c as u32) < 0x20 => { - return Err(Error::DirNameCollision { - host: name.to_string(), - }); - } - c => out.push(c), - } - } - // Trailing dot / space are illegal on Windows. - let trimmed = out.trim_end_matches([' ', '.']); +// Sanitizes ONE disc-path component: host-illegal chars and control bytes become `_`, trailing +// dot/space are stripped, a Windows reserved device name gets a `_` prefix. Names that +// collapse together are caught by the collision check. +fn sanitize_component(name: &str) -> String { + let mapped: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + let trimmed = mapped.trim_end_matches([' ', '.']); if trimmed.is_empty() { - return Err(Error::DirNameCollision { - host: name.to_string(), - }); + return "_".to_string(); } - // Windows reserved device names (case-insensitive, base before extension). - // `NUL` silently discards writes on Windows, so `NUL.cfg` (legal on - // UDF/Linux) must not pass through verbatim; prefix `_` instead of aborting. + // The device name is the stem before any extension, ignoring trailing spaces. let base = trimmed.split('.').next().unwrap_or(trimmed); - if is_windows_reserved(base) { - return Ok(format!("_{trimmed}")); + if is_windows_reserved(base.trim_end_matches(' ')) { + return format!("_{trimmed}"); } - Ok(trimmed.to_string()) + trimmed.to_string() } /// Whether `base` (the name component before any extension) matches a Windows /// reserved device name. These are reserved by the OS regardless of extension /// and silently alias a device (e.g. `NUL` discards writes). Case-insensitive. -fn is_windows_reserved(base: &str) -> bool { - const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", "CLOCK$"]; - if RESERVED.iter().any(|r| base.eq_ignore_ascii_case(r)) { +pub(super) fn is_windows_reserved(base: &str) -> bool { + let up = base.trim_end_matches(' ').to_ascii_uppercase(); + if matches!( + up.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" | "CLOCK$" + ) { return true; } - let up = base.to_ascii_uppercase(); - for prefix in ["COM", "LPT"] { - if let Some(rest) = up.strip_prefix(prefix) - && rest.len() == 1 - && matches!(rest.as_bytes()[0], b'1'..=b'9') - { - return true; - } - } - false + ["COM", "LPT"].iter().any(|p| { + up.strip_prefix(p).is_some_and(|d| { + let mut c = d.chars(); + matches!( + (c.next(), c.next()), + (Some('0'..='9' | '\u{B9}' | '\u{B2}' | '\u{B3}'), None) + ) + }) + }) } /// DVD VTS group key for a `VTS_xx_*` file name, else `None`. e.g. @@ -1708,27 +1781,41 @@ mod tests { /// Path sanitization rejects a host-illegal component in a disc file name. #[test] fn sanitize_rejects_illegal_component() { - assert!(sanitize_component("good_name.m2ts").is_ok()); - assert!(sanitize_component("..").is_err()); - assert!(sanitize_component("a/b").is_err()); - assert!(sanitize_component("a:b").is_err()); - assert!(sanitize_component("a*b").is_err()); + assert_eq!(sanitize_component("good_name.m2ts"), "good_name.m2ts"); + assert_eq!(sanitize_component(".."), "_"); + assert_eq!(sanitize_component("a/b"), "a_b"); + assert_eq!(sanitize_component("a:b"), "a_b"); + assert_eq!(sanitize_component("a*b"), "a_b"); // Windows reserved device names are substituted (prefixed `_`), not // rejected — a single such file must not abort the whole tree walk. - assert_eq!(sanitize_component("CON").unwrap(), "_CON"); - assert_eq!(sanitize_component("com1").unwrap(), "_com1"); - assert_eq!(sanitize_component("LPT9").unwrap(), "_LPT9"); + assert_eq!(sanitize_component("CON"), "_CON"); + assert_eq!(sanitize_component("com1"), "_com1"); + assert_eq!(sanitize_component("LPT9"), "_LPT9"); // Reserved base with an extension is still substituted (the device name // aliases regardless of extension on Windows). - assert_eq!(sanitize_component("NUL.cfg").unwrap(), "_NUL.cfg"); - assert_eq!(sanitize_component("conin$").unwrap(), "_conin$"); + assert_eq!(sanitize_component("NUL.cfg"), "_NUL.cfg"); + assert_eq!(sanitize_component("conin$"), "_conin$"); // A non-reserved lookalike is untouched. - assert_eq!(sanitize_component("COM10").unwrap(), "COM10"); - assert_eq!(sanitize_component("CONSOLE").unwrap(), "CONSOLE"); + assert_eq!(sanitize_component("COM10"), "COM10"); + // COM0 and the superscript digits are reserved too. + assert_eq!(sanitize_component("COM0"), "_COM0"); + assert_eq!(sanitize_component("LPT\u{b2}"), "_LPT\u{b2}"); + assert_eq!(sanitize_component("CLOCK$"), "_CLOCK$"); + assert_eq!(sanitize_component("CONSOLE"), "CONSOLE"); // A trailing dot/space is stripped, not rejected outright. - assert_eq!(sanitize_component("name. ").unwrap(), "name"); + assert_eq!(sanitize_component("name. "), "name"); // ...unless stripping empties it. - assert!(sanitize_component(". ").is_err()); + assert_eq!(sanitize_component(". "), "_"); + } + + /// Every host-illegal character is replaced, including the Windows path + /// separator, so a disc name cannot climb out of the extract root. + #[test] + fn sanitize_replaces_every_reserved_character() { + for c in ['/', '\\', ':', '<', '>', '"', '|', '?', '*'] { + assert_eq!(sanitize_component(&format!("a{c}b")), "a_b", "{c:?}"); + } + assert_eq!(sanitize_component("a\\..\\x"), "a_.._x"); } /// Two distinct disc paths that sanitize to the same host path are a hard @@ -2318,6 +2405,9 @@ mod tests { fn set_unit_base(&mut self, lba: u32) { self.last_unit_base = Some(lba); } + fn random_access(&self) -> bool { + false + } } let mut inner = Recorder { @@ -2330,6 +2420,10 @@ mod tests { assert_eq!(b.capacity_sectors(), 42, "capacity_sectors must forward"); b.set_speed(7200); b.set_unit_base(1234); + assert!( + !b.random_access(), + "random_access must forward, not default" + ); } assert_eq!(inner.last_speed, Some(7200), "set_speed must forward"); assert_eq!( @@ -2624,26 +2718,16 @@ mod tests { ); } - /// A raw control byte (below 0x20, distinct from the separately-rejected - /// NUL) in a disc-authored name must be rejected, not passed through into - /// the host filename. + /// A raw control byte (below 0x20) in a disc-authored name must be replaced, + /// not passed through into the host filename. #[test] fn sanitize_rejects_control_bytes() { - assert!( - sanitize_component("a\u{1}b").is_err(), - "0x01 must be rejected" - ); - assert!( - sanitize_component("a\nb").is_err(), - "0x0A (newline) must be rejected" - ); - assert!( - sanitize_component("a\u{1f}b").is_err(), - "0x1F must be rejected" - ); + assert_eq!(sanitize_component("a\u{1}b"), "a_b", "0x01 replaced"); + assert_eq!(sanitize_component("a\nb"), "a_b", "0x0A replaced"); + assert_eq!(sanitize_component("a\u{1f}b"), "a_b", "0x1F replaced"); // 0x20 (space) is NOT a control byte -- allowed mid-name (only // trimmed if trailing). - assert!(sanitize_component("a b").is_ok()); + assert_eq!(sanitize_component("a b"), "a b"); } /// The VTS group number must be EXACTLY 2 ASCII digits -- neither a @@ -3074,4 +3158,299 @@ mod tests { ); assert_eq!(dec.inner().0, READ_RETRIES + 1, "short reads are retried"); } + + fn planned( + size: u64, + inline: Option>, + extents: Vec, + ) -> PlannedFile { + PlannedFile { + host_rel: PathBuf::from("f.bin"), + disc_name: "f.bin".into(), + size, + inline, + extents, + unmapped: false, + } + } + + // One bad sector costs the rest of its ECC block (ends at 128), not the whole batch. + #[test] + fn bad_sector_holes_its_ecc_block_not_the_batch() { + let mut m = MemDisc::new(); + for i in 0..9u32 { + m.put(124 + i, [i as u8 + 1; 2048]); + } + m.bad.insert(125); + let len = 9 * SECTOR_BYTES as u32; + let pf = planned( + len as u64, + None, + vec![crate::udf::AbsExtent { + lba: 124, + len, + recorded: true, + }], + ); + let out = TmpDir::new("narrow"); + std::fs::create_dir_all(out.path()).unwrap(); + let mut dec = DecryptingSectorSource::new(m, DecryptKeys::None); + let (mut done, mut bad) = (0u64, 0u64); + let (fr, _) = extract_one_file( + &mut dec, + out.path(), + &pf, + len as u64, + &mut done, + &mut bad, + &ExtractOptions::default(), + ) + .unwrap(); + assert_eq!(fr.bytes_unreadable, 6 * SECTOR_BYTES as u64); + assert_eq!(fr.bytes_good, 3 * SECTOR_BYTES as u64); + assert_eq!(bad, 6 * SECTOR_BYTES as u64); + let data = std::fs::read(out.path().join("f.bin")).unwrap(); + assert_eq!(data[0], 0, "bad unit zero-filled"); + assert_eq!( + data[3 * SECTOR_BYTES], + 0, + "unit straddling the block end unread" + ); + assert_eq!(data[6 * SECTOR_BYTES], 7, "later unit kept"); + } + + // Counts reads; a read touching a `bad` LBA, or one of the first `fail_first`, fails. + struct CountingBad { + bad: fn(u32) -> bool, + fail_first: u32, + calls: u32, + fails: u32, + } + impl SectorSource for CountingBad { + fn capacity_sectors(&self) -> u32 { + 100_000 + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> Result { + self.calls += 1; + if self.calls <= self.fail_first || (lba..lba + count as u32).any(self.bad) { + self.fails += 1; + return Err(Error::DiscRead { + sector: lba as u64, + status: None, + sense: None, + }); + } + let need = count as usize * SECTOR_BYTES; + buf[..need].fill(0x22); + Ok(need) + } + } + + // Reads one READ_BATCH_SECTORS batch at LBA 1000: (lost, reads, failed reads, buf). + fn narrowed(bad: fn(u32) -> bool, fail_first: u32) -> (u64, u32, u32, Vec) { + let src = CountingBad { + bad, + fail_first, + calls: 0, + fails: 0, + }; + let mut dec = DecryptingSectorSource::new(src, DecryptKeys::None); + let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_BYTES]; + let lost = read_batch_narrowed(&mut dec, 1000, READ_BATCH_SECTORS, &mut buf).unwrap(); + (lost, dec.inner().calls, dec.inner().fails, buf) + } + + // Byte range of batch-relative sectors [a, b). + fn sectors(a: u32, b: u32) -> std::ops::Range { + a as usize * SECTOR_BYTES..b as usize * SECTOR_BYTES + } + + // Contiguous damage must cost a handful of reads per batch, not one retry loop per unit. + #[test] + fn narrowed_all_bad_batch_is_read_boundedly() { + let (lost, calls, _, buf) = narrowed(|l| (1000..2536).contains(&l), 0); + assert_eq!(lost, buf.len() as u64); + assert!(buf.iter().all(|&b| b == 0)); + // 2 batch reads + 4 bad units x 1 attempt, then the budget zero-fills the rest. + assert_eq!(calls, 6, "{calls} reads for one bad batch"); + } + + // One bad ECC block mid-batch loses that block only; data after it is still read. + #[test] + fn narrowed_bad_ecc_block_loses_only_that_block() { + let (lost, calls, _, buf) = narrowed(|l| (1600..1632).contains(&l), 0); + // Units start at 1000 + 3k: 1630..1633 straddles the block end, so 1632 goes unread. + assert_eq!(lost, 33 * SECTOR_BYTES as u64); + assert!(buf[sectors(600, 633)].iter().all(|&b| b == 0)); + assert!(buf[sectors(0, 600)].iter().all(|&b| b == 0x22)); + assert!(buf[sectors(633, 1536)].iter().all(|&b| b == 0x22)); + // 2 batch reads + 200 good units + 1 bad unit + 301 good units. + assert_eq!(calls, 2 + 200 + 1 + 301); + } + + // A lone bad sector loses its ECC block (the drive fails the whole block anyway). + #[test] + fn narrowed_single_bad_sector_loses_its_ecc_block() { + let (lost, _, _, buf) = narrowed(|l| l == 1700, 0); + // Unit 1699..1702 fails; skip to the first unit at or after the block end 1728. + assert_eq!(lost, 30 * SECTOR_BYTES as u64); + assert!(buf[sectors(699, 729)].iter().all(|&b| b == 0)); + assert_eq!(buf.iter().filter(|&&b| b == 0).count(), 30 * SECTOR_BYTES); + } + + // Scattered damage (every other unit bad) stops at the per-batch failed-read budget. + #[test] + fn narrowed_alternating_damage_is_capped() { + let (lost, calls, fails, buf) = narrowed(|l| (l - 1000) / 3 % 2 == 1, 0); + assert_eq!(fails, 2 + MAX_FAILED_UNIT_READS); + // Good units 0, 8 and 30 were read; the rest is zero-filled. + assert_eq!(calls, fails + 3); + assert_eq!(lost, buf.len() as u64 - 9 * SECTOR_BYTES as u64); + assert!(buf[sectors(24, 27)].iter().all(|&b| b == 0x22)); + } + + // Fails to decrypt (not a media error) for LBAs in `bad`. + struct DecryptBad(fn(u32) -> bool); + impl SectorSource for DecryptBad { + fn capacity_sectors(&self) -> u32 { + 100_000 + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> Result { + if (lba..lba + count as u32).any(self.0) { + return Err(Error::DecryptFailed); + } + let need = count as usize * SECTOR_BYTES; + buf[..need].fill(0x22); + Ok(need) + } + } + + // An undecryptable unit loses only itself: no ECC-block skip past it. + #[test] + fn narrowed_undecryptable_unit_loses_only_that_unit() { + let src = DecryptBad(|l| l == 1300); + let mut dec = DecryptingSectorSource::new(src, DecryptKeys::None); + let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_BYTES]; + let lost = read_batch_narrowed(&mut dec, 1000, READ_BATCH_SECTORS, &mut buf).unwrap(); + assert_eq!(lost, 3 * SECTOR_BYTES as u64); + assert!(buf[sectors(300, 303)].iter().all(|&b| b == 0)); + assert!(buf[sectors(303, 1536)].iter().all(|&b| b == 0x22)); + } + + // A transient whole-batch failure is recovered by one batch re-read, no unit reads. + #[test] + fn narrowed_transient_batch_failure_rereads_the_batch() { + let (lost, calls, _, buf) = narrowed(|_| false, 1); + assert_eq!(lost, 0); + assert_eq!(calls, 2); + assert!(buf.iter().all(|&b| b == 0x22)); + } + + // Extents covering less than the declared size: the gap is lost bytes, not a clean file. + #[test] + fn undercovered_extents_count_the_gap_as_unreadable() { + let mut m = MemDisc::new(); + m.put(100, [5; 2048]); + let len = SECTOR_BYTES as u32; + let size = 3 * SECTOR_BYTES as u64; + let pf = planned( + size, + None, + vec![crate::udf::AbsExtent { + lba: 100, + len, + recorded: true, + }], + ); + let out = TmpDir::new("undercover"); + std::fs::create_dir_all(out.path()).unwrap(); + let mut dec = DecryptingSectorSource::new(m, DecryptKeys::None); + let (mut done, mut bad) = (0u64, 0u64); + let (fr, _) = extract_one_file( + &mut dec, + out.path(), + &pf, + size, + &mut done, + &mut bad, + &ExtractOptions::default(), + ) + .unwrap(); + assert_eq!(fr.bytes_good, SECTOR_BYTES as u64); + assert_eq!(fr.bytes_unreadable, 2 * SECTOR_BYTES as u64); + assert_eq!(done, size); + assert_eq!(bad, 2 * SECTOR_BYTES as u64); + } + + // Inline data shorter than the declared size: same accounting. + #[test] + fn short_inline_data_counts_the_gap_as_unreadable() { + let pf = planned(5000, Some(vec![7u8; 100]), Vec::new()); + let out = TmpDir::new("inline_short"); + std::fs::create_dir_all(out.path()).unwrap(); + let mut dec = DecryptingSectorSource::new(MemDisc::new(), DecryptKeys::None); + let (mut done, mut bad) = (0u64, 0u64); + let (fr, _) = extract_one_file( + &mut dec, + out.path(), + &pf, + 5000, + &mut done, + &mut bad, + &ExtractOptions::default(), + ) + .unwrap(); + assert_eq!(fr.bytes_good, 100); + assert_eq!(fr.bytes_unreadable, 4900); + } + + // Reserved names with trailing spaces before the extension, and superscript digits. + #[test] + fn sanitize_substitutes_space_padded_and_superscript_reserved_names() { + assert_eq!(sanitize_component("NUL .txt"), "_NUL .txt"); + assert_eq!(sanitize_component("COM\u{B9}"), "_COM\u{B9}"); + assert_eq!(sanitize_component("lpt\u{B3}.x"), "_lpt\u{B3}.x"); + } + + // Illegal characters are substituted, not reported as a collision that aborts the run. + #[test] + fn sanitize_substitutes_illegal_chars() { + assert_eq!(sanitize_component("a:b"), "a_b"); + assert_eq!(sanitize_component("a\u{1}b"), "a_b"); + assert_eq!(sanitize_component(".."), "_"); + assert_eq!(sanitize_component(". "), "_"); + } + + // Two entries with the SAME name in one directory must collide, not overwrite. + #[test] + fn duplicate_identical_names_collide() { + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: vec![ + file("movie", 30, 31, b"a".to_vec(), false), + file("movie", 32, 33, b"b".to_vec(), false), + ], + subdirs: vec![], + }; + let mut disc = build_disc(root); + let out = TmpDir::new("dup_names"); + let err = clear_disc() + .extract_tree(&mut disc, out.path(), &ExtractOptions::default()) + .expect_err("duplicate names must collide"); + assert!(matches!(err, Error::DirNameCollision { .. })); + } } diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index b9bc2ef7..a5b788ff 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -147,6 +147,7 @@ fn probe_evo_streams( let mut audio: BTreeMap> = BTreeMap::new(); let mut remaining = EVO_PROBE_SECTORS; + let mut buf = vec![0u8; 512 * crate::consts::SECTOR_BYTES]; 'outer: for ext in extents { let mut lba = ext.start_lba; let mut left = ext.sector_count; @@ -158,8 +159,8 @@ fn probe_evo_streams( return Err(crate::error::Error::Halted); } let n = left.min(remaining).min(512) as u16; - let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES]; - match reader.read_sectors(lba, n, &mut buf, false) { + let chunk = &mut buf[..n as usize * crate::consts::SECTOR_BYTES]; + match reader.read_sectors(lba, n, chunk, false) { Ok(_) => {} // A live-drive Stop surfaces HERE, via `Drive::checked_exec` // failing with `Halted`. Swallowing it like other read errors @@ -167,9 +168,17 @@ fn probe_evo_streams( Err(crate::error::Error::Halted) => { return Err(crate::error::Error::Halted); } - Err(_) => break 'outer, + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + lba, + code = e.code(), + "evo probe read failed; title keeps the streams found so far" + ); + break 'outer; + } } - for pkt in demux.feed(&buf) { + for pkt in demux.feed(chunk) { collect_es(&pkt, &mut video, &mut video_pid, &mut audio); } lba += n as u32; @@ -348,6 +357,10 @@ const MAX_XPL_DEPTH: usize = 32; // small file into huge probe/memory cost. const MAX_XPL_TITLES: usize = 512; +// Max XPL file size. A real playlist is KBs; 1 MiB bounds the DOM roxmltree builds from +// disc-supplied XML. +const MAX_XPL_BYTES: usize = 1024 * 1024; + const MAX_HDDVD_CLIPS: usize = 512; const MAX_XPL_CLIPS_PER_TITLE: usize = 256; @@ -370,10 +383,18 @@ impl EvoProbeCache { extents: &[Extent], halt: Option<&crate::halt::Halt>, ) -> Result> { - let key: Vec<(u32, u32)> = extents - .iter() - .map(|e| (e.start_lba, e.sector_count)) - .collect(); + // Key on the head the probe actually reads (first EVO_PROBE_SECTORS), so + // titles sharing a head clip but differing later share one probe. + let mut budget = EVO_PROBE_SECTORS; + let mut key: Vec<(u32, u32)> = Vec::new(); + for e in extents { + if budget == 0 { + break; + } + let n = e.sector_count.min(budget); + key.push((e.start_lba, n)); + budget -= n; + } if let Some(hit) = self.seen.get(&key) { return Ok(hit.clone()); } @@ -546,14 +567,70 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec { titles } -/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present. -fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Option> { - let dir = udf_fs.find_dir("/ADV_OBJ")?; - let name = dir.entries.iter().find_map(|e| { +// Reads `path`; `Ok(None)` when it exceeds MAX_XPL_BYTES (no over-read). +fn read_xpl_capped( + reader: &mut dyn SectorSource, + udf_fs: &udf::UdfFs, + path: &str, +) -> Result>> { + let bytes = udf_fs.read_file_prefix(reader, path, MAX_XPL_BYTES + 1)?; + Ok((bytes.len() <= MAX_XPL_BYTES).then_some(bytes)) +} + +/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present. `Err` only on +/// `Halted`; an unreadable or oversized playlist is logged and reads as absent. +fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result>> { + let Some(dir) = udf_fs.find_dir("/ADV_OBJ") else { + return Ok(None); + }; + let Some(name) = dir.entries.iter().find_map(|e| { let lower = e.name.to_ascii_lowercase(); (!e.is_dir && lower.starts_with("vplst") && lower.ends_with(".xpl")).then(|| e.name.clone()) - })?; - udf_fs.read_file(reader, &format!("/ADV_OBJ/{name}")).ok() + }) else { + return Ok(None); + }; + // The name came from the directory listing, so a failure is a real error. + match read_xpl_capped(reader, udf_fs, &format!("/ADV_OBJ/{name}")) { + Ok(Some(b)) => Ok(Some(b)), + Ok(None) => { + tracing::warn!( + target: "freemkv::disc", + xpl = ?name, + code = crate::error::E_XPL_TOO_LARGE, + "playlist exceeds the parser cap; falling back to the per-clip heuristic" + ); + Ok(None) + } + Err(crate::error::Error::Halted) => Err(crate::error::Error::Halted), + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + xpl = ?name, + code = e.code(), + "playlist unreadable; falling back to the per-clip heuristic" + ); + Ok(None) + } + } +} + +// Filters a file's (lba, sectors) list to readable extents. The flag is set when a non-empty +// extent at lba 0 was dropped, so the plan is short of the declared size. +fn usable_extents(file_exts: &[(u32, u32)]) -> (Vec, bool) { + let mut truncated = false; + let mut extents = Vec::new(); + for &(lba, sectors) in file_exts { + if sectors > 0 && lba == 0 { + truncated = true; + } + if sectors > 0 && lba > 0 { + extents.push(Extent { + start_lba: lba, + sector_count: sectors, + }); + } + } + (extents, truncated) } // Composes DiscTitles from parsed XPL titles: resolves clips to extents, @@ -578,6 +655,11 @@ fn compose_xpl_titles( if t.clips.iter().any(|c| unusable.contains(&c.evo)) { continue; } + // A clip absent from the disc (or dropped by MAX_HDDVD_CLIPS) is equally + // unrenderable: drop the title rather than emit it short. + if t.clips.iter().any(|c| !clip_extents.contains_key(&c.evo)) { + continue; + } let mut extents = Vec::new(); let mut size_bytes = 0u64; let mut parts = Vec::new(); @@ -704,16 +786,10 @@ impl Disc { return Err(crate::error::Error::Halted); } let mut extents = Vec::new(); + let mut truncated = false; match udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) { Ok(file_exts) => { - for (lba, sectors) in file_exts { - if sectors > 0 && lba > 0 { - extents.push(Extent { - start_lba: lba, - sector_count: sectors, - }); - } - } + (extents, truncated) = usable_extents(&file_exts); } Err(crate::error::Error::UdfUnrecordedExtent { .. }) => { // Marking unusable drops every title naming this clip; @@ -744,7 +820,7 @@ impl Disc { // `Ok` with NO usable extent (empty/filtered AD list) is unusable // too: else a zero-byte `FEATURE_2.EVO` beside a healthy part one // silently composed a whole-runtime title missing half the movie. - if extents.is_empty() { + if extents.is_empty() || truncated { if unusable.insert(name.to_ascii_lowercase()) { // Not the neighbouring 6017: an empty AD list is not an // unrecorded extent, and flattening the two would account a @@ -764,7 +840,7 @@ impl Disc { // Authoritative composition from `ADV_OBJ/VPLST*.XPL`, if present and // parseable. The clip-name heuristic below is the fallback when it's // absent, unparseable, or resolves to no on-disc clips. - if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) { + if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs)? { let composed = compose_xpl_titles( reader, &parse_xpl_titles(&xpl), @@ -997,25 +1073,6 @@ mod tests { assert!(parse_vti_clip_order(b"not a vti").is_empty()); } - #[test] - fn parse_vti_clip_order_caps_hits_on_a_crafted_vti() { - // A crafted VTI packed with far more than MAX_VTI_HITS `.EVO` tokens must - // not scan/collect them all (a CPU/memory amplification on a routine - // scan). The result is capped, and parsing stays fast. - let mut vti = Vec::with_capacity(1_000_000); - vti.extend_from_slice(HDDVD_VTI_MAGIC); - // ~160k tokens of the form "X.EVO\0" — well over the 8192 cap. - for _ in 0..(MAX_VTI_HITS * 20) { - vti.extend_from_slice(b"X.EVO\0"); - } - let out = parse_vti_clip_order(&vti); - assert!( - out.len() <= MAX_VTI_HITS, - "collected hits capped at MAX_VTI_HITS, got {}", - out.len() - ); - } - #[test] fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() { // Two equal-size buckets must resolve to the SAME winner every call: @@ -1034,6 +1091,8 @@ mod tests { put(&mut vti, 0x290, "B2.EVO"); let first = parse_vti_clip_order(&vti); + // The tie goes to the bucket with the smallest offset (A). + assert_eq!(first, vec!["A1.EVO".to_string(), "A2.EVO".to_string()]); for _ in 0..20 { assert_eq!( parse_vti_clip_order(&vti), @@ -1330,6 +1389,68 @@ mod tests { ); } + // A file whose only dropped extent is a non-empty one at abs LBA 0 must not compose + // a short clip: the scan wiring (`|| truncated`) has to refuse it, with its own code. + #[test] + fn scan_hddvd_does_not_compose_a_feature_over_a_part_with_a_dropped_lba0_extent() { + let mut disc = MemDisc::new(); + let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]); + let files = vec![ + file("FEATURE_1.EVO", 100, 5000, 10 * 2048, false), + file("FEATURE_2.EVO", 101, 8000, 14 * 2048, false), + file("TRAILER.EVO", 102, 12000, 2 * 2048, false), + file_with("HVA00001.VTI", 103, 15000, vti, false), + ]; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "HVDVD_TS".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files, + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + // Two short ADs: 10 sectors at rel LBA 0, then 4 at 8000. + let mut icb = build_file_icb(14 * 2048, 0, false); + icb[212..216].copy_from_slice(&16u32.to_le_bytes()); + icb[216..220].copy_from_slice(&(10u32 * 2048).to_le_bytes()); + icb[224..228].copy_from_slice(&(4u32 * 2048).to_le_bytes()); + icb[228..232].copy_from_slice(&8000u32.to_le_bytes()); + disc.put_bytes(PART_START + 101, &icb); + let mut udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + // Data LBAs become absolute, so rel LBA 0 is abs LBA 0. + udf.set_partition_start(0); + + let (titles, events) = crate::testlog::capture(|| { + Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan") + }); + + assert!( + !titles.iter().any(|t| t.playlist == "FEATURE"), + "a part with a dropped lba-0 extent must not be composed short; got {:?}", + titles + .iter() + .map(|t| (&t.playlist, t.size_bytes)) + .collect::>() + ); + assert!(titles.iter().any(|t| t.playlist == "FEATURE_1.EVO")); + let line = events + .iter() + .find(|e| e.target == "freemkv::disc" && e.field("clip") == Some("\"FEATURE_2.EVO\"")) + .unwrap_or_else(|| panic!("the dropped clip must be logged; got {events:?}")); + assert_eq!( + line.field("code"), + Some(crate::error::E_UDF_NO_USABLE_EXTENT.to_string().as_str()), + "{line:?}" + ); + } + // ── codec sniffing ──────────────────────────────────────────────────── #[test] @@ -1986,6 +2107,16 @@ mod tests { assert_eq!(parse_xpl_titles(tricky.as_bytes()).len(), 2); } + // More than MAX_XPL_DEPTH fake open tags inside a comment / PI must not count as nesting. + #[test] + fn xpl_depth_ignores_open_tags_inside_comments_and_pis() { + let fake = "".repeat(MAX_XPL_DEPTH + 10); + assert!(xpl_depth_within_limit(&format!(""))); + assert!(xpl_depth_within_limit(&format!(""))); + // Control: the same tags as real elements are over the limit. + assert!(!xpl_depth_within_limit(&format!("{fake}"))); + } + // Builds a UDF with HVDVD_TS/ .evo clips plus an ADV_OBJ/VPLST000.XPL // carrying `xpl`, so scan_hddvd_titles takes the playlist path. fn make_hddvd_fs_xpl( @@ -2084,6 +2215,36 @@ mod tests { assert!(!mm.extents.is_empty()); } + // An XPL whose titles all name absent clips composes to nothing; the scan must fall + // back to the per-clip heuristic instead of listing zero titles. + #[test] + fn scan_hddvd_falls_back_when_xpl_composes_to_nothing() { + let mut disc = MemDisc::new(); + let xpl = r#" + <PrimaryAudioVideoClip src="GONE.EVO"/>"#; + let udf = make_hddvd_fs_xpl(&mut disc, &[("REAL.EVO", 100, 5000)], xpl.as_bytes()); + let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan"); + assert_eq!(titles.len(), 1, "heuristic fallback lists the real clip"); + assert_eq!(titles[0].playlist, "REAL.EVO"); + } + + // Many XPL titles naming one clip must cost ONE probe (the XPL path's memo). + #[test] + fn compose_xpl_titles_probes_once_for_titles_sharing_a_clip() { + const TITLES: usize = 64; + let mut xpl = String::from(r#""#); + for _ in 0..TITLES { + xpl.push_str(r#"<PrimaryAudioVideoClip src="A.EVO"/>"#); + } + xpl.push_str(""); + let mut disc = MemDisc::new(); + let udf = make_hddvd_fs_xpl(&mut disc, &[("A.EVO", 4, 5000)], xpl.as_bytes()); + let mut counter = ProbeCounter::new(disc, vec![PART_START + 5000]); + let titles = Disc::scan_hddvd_titles(&mut counter, &udf, None).expect("scan"); + assert_eq!(titles.len(), TITLES, "every XPL title is composed"); + assert_eq!(counter.hits[0], 1, "one probe for {TITLES} titles"); + } + // ── parse_vti_clip_order: bound / cap / termination edge cases ──────── // MAX_VTI_HITS must be exact, not off-by-one. All entries here are @@ -2253,6 +2414,198 @@ mod tests { ); } + // ── audit b03-hddvd fixes ── + + #[test] + fn usable_extents_flags_a_dropped_lba0_data_extent() { + let (exts, truncated) = usable_extents(&[(0, 10), (500, 4)]); + assert_eq!(exts.len(), 1); + assert!(truncated, "lba-0 data extent dropped => truncated plan"); + let (_, truncated) = usable_extents(&[(0, 0), (500, 4)]); + assert!(!truncated, "empty extent is harmless"); + } + + #[test] + fn compose_xpl_titles_drops_a_title_naming_an_absent_clip() { + let clip_extents: BTreeMap)> = [( + "a.evo".to_string(), + ( + "A.EVO".to_string(), + 1000u64, + vec![Extent { + start_lba: 1, + sector_count: 1, + }], + ), + )] + .into_iter() + .collect(); + let clip = |evo: &str, b: f64, e: f64| XplClip { + evo: evo.to_string(), + begin_secs: b, + end_secs: e, + }; + let xpl_titles = vec![XplTitle { + number: 1, + name: "T".to_string(), + duration_secs: 20.0, + clips: vec![clip("a.evo", 0.0, 10.0), clip("gone.evo", 10.0, 20.0)], + chapters: vec![], + }]; + let mut disc = MemDisc::new(); + let titles = compose_xpl_titles( + &mut disc, + &xpl_titles, + &clip_extents, + &std::collections::HashSet::new(), + None, + ) + .expect("compose"); + assert!( + titles.is_empty(), + "title with an absent clip must be dropped" + ); + } + + #[test] + fn probe_cache_shares_titles_with_the_same_probed_head() { + let ext = |l, n| Extent { + start_lba: l, + sector_count: n, + }; + let mut counter = ProbeCounter::new(MemDisc::new(), vec![1000]); + let mut cache = EvoProbeCache::default(); + // Both extent lists are identical over the first EVO_PROBE_SECTORS. + let a = [ext(1000, EVO_PROBE_SECTORS), ext(5000, 10)]; + let b = [ext(1000, EVO_PROBE_SECTORS), ext(6000, 10)]; + cache.streams(&mut counter, &a, None).expect("a"); + cache.streams(&mut counter, &b, None).expect("b"); + assert_eq!(counter.hits[0], 1, "same probed head must cost one probe"); + } + + // A disc with `ADV_OBJ/VPLST000.XPL` of `len` spaces. + fn xpl_disc(len: usize) -> (MemDisc, crate::udf::UdfFs) { + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "ADV_OBJ".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files: vec![file_with("VPLST000.XPL", 40, 4000, vec![b' '; len], true)], + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + (disc, udf) + } + + // Fails every read with the error `mk` builds. + struct FailReader(fn() -> crate::error::Error); + + impl SectorSource for FailReader { + fn read_sectors( + &mut self, + _lba: u32, + _count: u16, + _buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + Err((self.0)()) + } + } + + fn disc_read_err() -> crate::error::Error { + crate::error::Error::DiscRead { + sector: 0, + status: None, + sense: None, + } + } + + #[test] + fn read_xpl_capped_rejects_an_oversized_playlist() { + let (mut disc, udf) = xpl_disc(MAX_XPL_BYTES + 1); + let r = read_xpl_capped(&mut disc, &udf, "/ADV_OBJ/VPLST000.XPL"); + assert!(matches!(r, Ok(None))); + let (r, events) = + crate::testlog::capture(|| read_adv_obj_xpl(&mut disc, &udf).expect("no halt")); + assert!(r.is_none()); + let line = events + .iter() + .find(|e| e.target == "freemkv::disc" && e.field("xpl") == Some("\"VPLST000.XPL\"")) + .unwrap_or_else(|| panic!("oversized playlist must be logged; got {events:?}")); + assert_eq!(line.level, tracing::Level::WARN); + assert_eq!( + line.field("code"), + Some(crate::error::E_XPL_TOO_LARGE.to_string().as_str()), + "{line:?}" + ); + } + + #[test] + fn read_xpl_capped_accepts_a_playlist_of_exactly_the_cap() { + let (mut disc, udf) = xpl_disc(MAX_XPL_BYTES); + let r = read_xpl_capped(&mut disc, &udf, "/ADV_OBJ/VPLST000.XPL").expect("read"); + assert_eq!(r.map(|b| b.len()), Some(MAX_XPL_BYTES)); + } + + #[test] + fn read_adv_obj_xpl_logs_an_unreadable_playlist_and_reads_as_absent() { + let (_, udf) = xpl_disc(100); + let mut bad = FailReader(disc_read_err); + let (r, events) = + crate::testlog::capture(|| read_adv_obj_xpl(&mut bad, &udf).expect("no halt")); + assert!(r.is_none()); + let line = events + .iter() + .find(|e| e.field("xpl") == Some("\"VPLST000.XPL\"")) + .unwrap_or_else(|| panic!("unreadable playlist must be logged; got {events:?}")); + assert_eq!( + line.field("code"), + Some(disc_read_err().code().to_string().as_str()), + "{line:?}" + ); + } + + #[test] + fn read_adv_obj_xpl_propagates_halted() { + let (_, udf) = xpl_disc(100); + let mut halted = FailReader(|| crate::error::Error::Halted); + assert!(matches!( + read_adv_obj_xpl(&mut halted, &udf), + Err(crate::error::Error::Halted) + )); + } + + #[test] + fn probe_evo_streams_logs_a_read_failure_and_keeps_going_without_streams() { + let mut bad = FailReader(disc_read_err); + let ext = Extent { + start_lba: 777, + sector_count: 4, + }; + let (streams, events) = crate::testlog::capture(|| { + probe_evo_streams(&mut bad, std::slice::from_ref(&ext), None).expect("probe") + }); + assert!(streams.is_empty()); + let line = events + .iter() + .find(|e| e.target == "freemkv::disc" && e.field("lba") == Some("777")) + .unwrap_or_else(|| panic!("probe failure must be logged; got {events:?}")); + assert_eq!(line.level, tracing::Level::WARN); + assert_eq!( + line.field("code"), + Some(disc_read_err().code().to_string().as_str()), + "{line:?}" + ); + } + // ── read_adv_obj_xpl: prefix AND suffix are both required ────────────── /// A file matching the `vplst` prefix but NOT the `.xpl` suffix must not @@ -2284,7 +2637,7 @@ mod tests { lay_dir(&mut disc, &root); let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); assert!( - read_adv_obj_xpl(&mut disc, &udf).is_none(), + read_adv_obj_xpl(&mut disc, &udf).expect("read").is_none(), "prefix match alone (not ending .xpl) must not select a file" ); } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 4a96e5fd..e79e7fb0 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -4,9 +4,9 @@ //! never parses MPLS/CLPI/UDF directly. //! //! Usage: -//! let disc = Disc::scan(&mut session)?; -//! for title in disc.titles() { ... } -//! for stream in title.streams() { ... } +//! let disc = Disc::scan(&mut drive, &ScanOptions::default())?; +//! for title in &disc.titles { ... } +//! for stream in &title.streams { ... } mod bluray; mod dvd; @@ -18,7 +18,7 @@ mod hddvd; pub(crate) mod pgs_forced_probe; pub mod profile; #[cfg(test)] -mod scan_order_tests; +pub(crate) mod scan_order_tests; use crate::drive::Drive; use crate::error::{Error, Result}; @@ -817,7 +817,7 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut } /// Calculate how many bytes of bad/unreadable data fall within a title's extents. -/// `pub(crate)` so autorip can use it for main-movie lost_ms computation. +/// Public so autorip can use it for main-movie lost_ms computation. pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 { if bad_ranges.is_empty() || title.extents.is_empty() { return 0; @@ -1211,7 +1211,8 @@ impl AudioChannels { 1 => AudioChannels::Mono, 3 => AudioChannels::Stereo, 6 => AudioChannels::Surround51, - 12 => AudioChannels::Surround71, + // 12 is the stereo + multichannel combo; the layout is not knowable here. + 12 => AudioChannels::Unknown, other => { tracing::warn!(audio_format = other, "unknown MPLS audio_format byte"); AudioChannels::Unknown @@ -1792,7 +1793,7 @@ impl Disc { // longer defaults to BluRay or defers UHD/FMTS to the full scan. let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]); let encrypted = aacs_dir_present(&udf_fs); - let layers = if capacity > 24_000_000 { 2 } else { 1 }; + let layers = Self::layers_for(format, capacity); Ok(DiscId { volume_id: udf_fs.volume_id, @@ -1804,6 +1805,24 @@ impl Disc { }) } + // Layer count inferred from capacity against the format's per-layer size. + fn layers_for(format: DiscFormat, capacity: u32) -> u8 { + // Upper sector bounds of (1, 2) layers; above the second is 3 (BD-100, HD DVD TL). + let (one, two) = match format { + DiscFormat::Dvd => (2_400_000, u32::MAX), + DiscFormat::HdDvd => (8_000_000, 16_000_000), + DiscFormat::BluRay | DiscFormat::Uhd | DiscFormat::Fmts => (12_500_000, 40_000_000), + DiscFormat::Unknown => (12_500_000, u32::MAX), + }; + if capacity <= one { + 1 + } else if capacity <= two { + 2 + } else { + 3 + } + } + /// Disc capacity in GB pub fn capacity_gb(&self) -> f64 { self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0) @@ -1929,7 +1948,7 @@ impl Disc { fn scan_live(session: &mut Drive, opts: &ScanOptions) -> Result { let dvd = session.disc_is_dvd(); // Max read speed; removes riplock on DVD. - session.set_speed(0xFFFF); + session.set_speed(Drive::SPEED_MAX_KBPS); // CSS bus-auth before any read, else the UDF prefetch hits scrambled VOB extents. if dvd { Self::css_bus_step(session)?; @@ -1939,8 +1958,10 @@ impl Disc { let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?; tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read"); // Pre-read small files (AACS, MPLS, CLPI, META, *.bdmv): one command each otherwise. - if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) { - buffered.prefetch_ranges(&ranges)?; + match udf_fs.metadata_sector_ranges(&mut buffered) { + Ok(ranges) => buffered.prefetch_ranges(&ranges)?, + Err(Error::Halted) => return Err(Error::Halted), + Err(_) => {} // prefetch is optional } let aacs = if aacs_dir_present(&udf_fs) { @@ -2388,7 +2409,9 @@ impl Disc { out.push((rel, bytes)); } Err(Error::Halted) => return Err(Error::Halted), - Err(_) => {} // unreadable file: skipped, as when absent + Err(e) => { + tracing::warn!(target: "freemkv::scan", file = %rel, code = e.code(), "structure file unreadable; skipped"); + } } } if omitted > 0 { @@ -2522,6 +2545,10 @@ impl Disc { // rip leaves it off since the muxer detects forced without a second read. if opts.probe_forced_subtitles { Self::probe_forced_subtitles_for_bdts_titles(reader, &mut titles, halt); + // The probe swallows a Stop; surface it as the live scan does. + if let Some(h) = halt { + h.check()?; + } } crate::labels::fill_defaults(&mut titles); @@ -2529,14 +2556,13 @@ impl Disc { // layers. Region coding is not decoded yet — every disc reports // Region-free (correct for UHD; a stub until BD/DVD region detection). let format = Self::detect_disc_format(reader, &udf_fs, &titles); - let layers = if capacity > 24_000_000 { 2 } else { 1 }; + let layers = Self::layers_for(format, capacity); let region = DiscRegion::Free; // 6. CSS detection for DVDs is deferred to `Disc::scan`'s drive-auth path // (has `&mut Drive`), run after this returns — the reader-based crack path // is NOT run here: on a CSS disc it would fail ~50,000 sectors one-by-one. let css = None; - let encrypted = encrypted || css.is_some(); tracing::info!( target: "freemkv::scan", @@ -2827,7 +2853,10 @@ impl Disc { // one format but enumerated as another (e.g. BD titles tagged HdDvd). if udf_fs.find_dir("/BDMV").is_some() { // Only the Type-and-Version record (first record) is needed. - if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) { + let mkb_paths = crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::Mkb); + if let Ok(mkb) = + crate::aacs::read_first(&mkb_paths, |p| udf_fs.read_file_prefix(reader, p, 64)) + { match mkb_type(&mkb).map(|t| t.generation()) { Some(AacsVersion::V21) => return DiscFormat::Fmts, Some(AacsVersion::V20) => return DiscFormat::Uhd, @@ -2872,29 +2901,7 @@ impl Disc { } fn read_capacity(session: &mut Drive) -> Result { - let cdb = [ - crate::scsi::SCSI_READ_CAPACITY, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - ]; - let mut buf = [0u8; 8]; - let result = session.exec( - &cdb, - crate::scsi::DataDirection::FromDevice, - &mut buf, - 5_000, - )?; - // Share the decoder with `Drive::capacity` rather than re-deriving it — a - // hand-rolled copy previously dropped the short-transfer check, so a drive - // answering GOOD with an empty data phase decoded to a 1-sector disc. - crate::drive::decode_read_capacity(&buf, result.bytes_transferred) + session.read_capacity() } } @@ -3073,23 +3080,9 @@ fn is_plain_file_name(name: &str) -> bool { }) { return false; } - // Windows reserved device names, matched on the stem with trailing spaces ignored. - let stem = name.split('.').next().unwrap_or(name).trim_end_matches(' '); - let stem = stem.to_ascii_uppercase(); - let numbered = |p: &str| { - stem.strip_prefix(p).is_some_and(|d| { - let mut c = d.chars(); - matches!( - (c.next(), c.next()), - (Some('0'..='9' | '\u{B9}' | '\u{B2}' | '\u{B3}'), None) - ) - }) - }; - !(matches!( - stem.as_str(), - "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" - ) || numbered("COM") - || numbered("LPT")) + // Matched on the stem with trailing spaces ignored. + let stem = name.split('.').next().unwrap_or(name); + !extract::is_windows_reserved(stem) } impl Disc { @@ -3274,7 +3267,7 @@ impl Disc { /// Path to the mapfile for a given output path. /// /// For `/dev/null` output, returns - /// `{temp_dir}/{volume_id_or_title}.mapfile` (temp dir is + /// `{temp_dir}/{volume_id_or_title}-{pid}.mapfile` (temp dir is /// `TMPDIR`-aware and cross-platform). For regular files, returns /// `{path}.mapfile`. pub fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf { @@ -3292,7 +3285,9 @@ impl Disc { } }) .collect(); - std::env::temp_dir().join(format!("{name}.mapfile")) + // Per-process, so a stale or concurrent same-label run is never resumed. + let pid = std::process::id(); + std::env::temp_dir().join(format!("{name}-{pid}.mapfile")) } else { mapfile_path_for(path) } @@ -4160,6 +4155,12 @@ mod tests { assert!(d.contains("redacted"), "AacsState missing marker: {d}"); for k in [ + Key::Device(vec![crate::aacs::types::DeviceKey { + key: [0xD5; 16], + node: 0, + uv: 0, + u_mask_shift: 0, + }]), Key::Unit(vec![(1, [0xD5; 16])]), Key::Volume([0xD5; 16]), Key::Processing(vec![[0xD5; 16]]), @@ -7102,6 +7103,30 @@ mod tests { ); } + // Many ranges: sorted largest-first, capped at 50 with the overflow counted. + // Input is ascending, so a flipped sort keeps the smallest and reports its gap. + // bps = 2048 B/s, so range i (i+1 sectors) lasts (i+1) * 1000 ms. + #[test] + fn locate_ranges_sorts_largest_first_and_truncates_at_fifty() { + let mut title = title_with_size(204_800, vec![]); + title.duration_secs = 100.0; + let raw: Vec<(u64, u64)> = (0..53u64) + .map(|i| (i * 100 * 2048, (i + 1) * 2048)) + .collect(); + let r = locate_ranges(&raw, &title); + assert_eq!(r.num_ranges, 53); + assert_eq!(r.truncated, 3); + assert_eq!(r.ranges.len(), 50); + assert_eq!(r.largest_gap_ms, 53_000.0); + assert_eq!(r.ranges[0].count, 53); + assert_eq!(r.ranges[49].count, 4, "the three smallest were dropped"); + assert!( + r.ranges + .windows(2) + .all(|w| w[0].duration_ms > w[1].duration_ms) + ); + } + // bps computed exactly 0.0: duration_ms/main_at_risk_ms must stay 0.0, // never inf/NaN. Kills `bps > 0.0` flipped to `>=` at both sites (the // boundary exactly zero would wrongly take the division branch). @@ -8711,14 +8736,118 @@ mod tests { disc.meta_title = Some("A-B_c1 d!é".into()); assert_eq!( disc.mapfile_for(std::path::Path::new("/dev/null")), - std::env::temp_dir().join("A-B_c1_d__.mapfile") + std::env::temp_dir().join(format!("A-B_c1_d__-{}.mapfile", std::process::id())) ); // The UDF volume id is the fallback when the disc carries no META/DL // title. disc.meta_title = None; assert_eq!( disc.mapfile_for(std::path::Path::new("/dev/null")), - std::env::temp_dir().join("VOLUME_ID.mapfile") + std::env::temp_dir().join(format!("VOLUME_ID-{}.mapfile", std::process::id())) + ); + } + + // Two runs (or same-label discs) must not share a /dev/null mapfile. + #[test] + fn mapfile_for_dev_null_is_per_process() { + let disc = make_test_disc(1_000, "BDROM"); + let name = disc.mapfile_for(std::path::Path::new("/dev/null")); + let name = name.file_name().and_then(|n| n.to_str()).unwrap_or(""); + assert!(name.contains(&std::process::id().to_string()), "{name}"); + } + + // STN audio_format 12 is the stereo+multichannel combo, not a 7.1 layout. + #[test] + fn audio_format_combo_is_not_claimed_as_7_1() { + assert_eq!(AudioChannels::from_audio_format(12), AudioChannels::Unknown); + } + + #[test] + fn layers_follow_the_format_capacity() { + for (fmt, sectors, want) in [ + (DiscFormat::Dvd, 2_298_496, 1), + (DiscFormat::Dvd, 4_173_824, 2), + (DiscFormat::HdDvd, 7_300_000, 1), + (DiscFormat::HdDvd, 14_600_000, 2), + (DiscFormat::HdDvd, 22_000_000, 3), + (DiscFormat::BluRay, 12_219_392, 1), + (DiscFormat::BluRay, 24_438_784, 2), + (DiscFormat::Uhd, 48_878_592, 3), + ] { + assert_eq!(Disc::layers_for(fmt, sectors), want, "{fmt:?} {sectors}"); + } + } + + // A Stop during the image scan's forced-subtitle probe must not yield Ok(Disc). + #[test] + fn scan_image_stopped_during_forced_probe_is_halted() { + use crate::udf::fixture::*; + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let halt = crate::halt::Halt::new(); + halt.cancel(); + let opts = ScanOptions { + halt: Some(halt), + probe_forced_subtitles: true, + ..ScanOptions::default() + }; + let err = Disc::scan_image(&mut disc, 1_000, &opts).expect_err("stopped"); + assert_eq!(err.code(), Error::Halted.code()); + } + + // The primary MKB may be unreadable while the /AACS/DUPLICATE copy is intact. + #[test] + fn detect_disc_format_falls_back_to_the_duplicate_mkb() { + use crate::udf::fixture::*; + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "BDMV".into(), + icb_lba: 12, + dir_data_lba: 13, + files: Vec::new(), + subdirs: vec![], + }, + DirSpec { + name: "AACS".into(), + icb_lba: 14, + dir_data_lba: 15, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "DUPLICATE".into(), + icb_lba: 17, + dir_data_lba: 18, + files: vec![file_with( + "MKB_RO.inf", + 16, + 5000, + mkb_type_record(0x4814_1003), + true, + )], + subdirs: vec![], + }], + }, + ], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + assert_eq!( + Disc::detect_disc_format(&mut disc, &udf, &[]), + DiscFormat::Uhd ); } diff --git a/src/disc/pgs_forced_probe.rs b/src/disc/pgs_forced_probe.rs index 87bb0e4b..58503058 100644 --- a/src/disc/pgs_forced_probe.rs +++ b/src/disc/pgs_forced_probe.rs @@ -14,7 +14,7 @@ use crate::mux::ts::TsDemuxer; use crate::sector::SectorSource; use std::collections::HashMap; -const SECTOR_BYTES: usize = 2048; +use crate::consts::SECTOR_BYTES; // Read the clip in ~2 MiB chunks: a whole number of AACS aligned units (3 sectors / 6144 B; // 1023 = 341 units). const CHUNK_SECTORS: u16 = 1023; @@ -548,6 +548,11 @@ pub(crate) fn probe_and_set_forced( } } if let Some(reason) = cut_short { + // Exhausted is judged from THIS extent's tracks only; a later extent may still owe + // evidence for a track the cache answered here. + if reason == StopReason::Exhausted { + continue; + } stop = reason; break 'outer; } @@ -2109,6 +2114,26 @@ mod tests { } } + // A truncated run may still assert "not forced" (positive evidence: a non-forced set was + // seen) when the shape gate allows demotion, but never a forced verdict (an absence claim). + #[test] + fn a_truncated_run_asserts_only_a_demotable_not_forced_verdict() { + let full = TrackEvidence { + observed: true, + non_forced: true, + displays: 20, + ..Default::default() + }; + let flagged = TrackEvidence { + observed: true, + forced_seen: true, + displays: 20, + ..Default::default() + }; + let ev = HashMap::from([(0x1200u16, full), (0x1201u16, flagged)]); + assert_eq!(verdicts(&ev, false), HashMap::from([(0x1200u16, false)])); + } + // Coverage is what makes a memo replayable: an entry from a thin sample must not answer a // question needing a thorough one. #[test] @@ -2274,4 +2299,48 @@ mod tests { probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None); assert!(forced_flag(&title, pid), "nothing was left unread"); } + + // An early Exhausted decided on one extent's fresh tracks must not skip a later extent that + // still owes evidence for a track the cache answered on the first. + #[test] + fn exhausted_on_one_extent_does_not_skip_a_later_extent_owing_another_track() { + let (x, y) = (0x1200u16, 0x1201u16); + // A spans several read chunks so X settles mid-extent. + let a = Extent { + start_lba: 0, + sector_count: 3000, + }; + let b = Extent { + start_lba: 4002, + sector_count: 30, + }; + let shape = |pid, first_sector, count, forced| TrackShape { + pid, + first_sector, + period_sectors: 3, + count, + forced, + }; + let mut reader = + SyntheticClipReader::new(vec![shape(x, 0, 4, false), shape(y, 4002, 2, true)]); + let mut title = pgs_title(x, false); + title.streams.push(subtitle_stream(y, false)); + title.extents = vec![a, b]; + let mut cache = ForcedProbeCache::new(); + cache.insert( + (a.start_lba, a.sector_count, y), + CachedEvidence { + evidence: TrackEvidence { + observed: true, + ..TrackEvidence::default() + }, + covered: a.sector_count, + }, + ); + probe_and_set_forced(&mut reader, &mut title, &mut cache, None); + assert!( + reader.reads.iter().any(|&(lba, _)| lba >= b.start_lba), + "extent B still owes evidence for track Y and must be read" + ); + } } diff --git a/src/disc/profile.rs b/src/disc/profile.rs index 89b8db9e..093a2ae8 100644 --- a/src/disc/profile.rs +++ b/src/disc/profile.rs @@ -440,6 +440,27 @@ mod tests { assert!(p.subtitles.iter().all(|s| !s.default)); } + #[test] + fn secondary_first_or_only_track_is_never_default() { + let mut t = DiscTitle::empty(); + t.streams = vec![ + video(Codec::Hevc, Resolution::R2160p, true), + video(Codec::Hevc, Resolution::R2160p, false), + audio("eng", true, LabelPurpose::Commentary, "Director"), + audio("eng", false, LabelPurpose::Normal, ""), + ]; + let p = TitleProfile::from_title(&t, 0, true); + assert!(!p.video[0].default && p.video[1].default); + assert!(!p.audio[0].default && p.audio[1].default); + // Only a secondary track of each kind: nothing is default. + t.streams = vec![ + video(Codec::Hevc, Resolution::R2160p, true), + audio("eng", true, LabelPurpose::Commentary, ""), + ]; + let p = TitleProfile::from_title(&t, 0, true); + assert!(!p.video[0].default && !p.audio[0].default); + } + #[test] fn second_default_cleared_when_two_non_secondary_audio() { // DVD title: BOTH audio tracks are non-secondary. Only the first keeps diff --git a/src/disc/scan_order_tests.rs b/src/disc/scan_order_tests.rs index 7faaca42..ff56e769 100644 --- a/src/disc/scan_order_tests.rs +++ b/src/disc/scan_order_tests.rs @@ -256,6 +256,11 @@ fn bd_disc(aacs: Option) -> MemDisc { mem } +// A drive over an unencrypted BD that scans successfully. +pub(crate) fn scannable_drive() -> Drive { + Rig::new(bd_disc(None), |_| {}).drive +} + struct Rig { drive: Drive, log: Log, @@ -917,12 +922,17 @@ fn bus_step_stop_and_transport_share_bus_step_guard() { ); } let enc = include_str!("encrypt.rs"); + // Comment lines are dropped so a commented-out call cannot satisfy the check. let body = |src: &'static str, f: &str| { let i = src.find(f).unwrap(); - &src[i..i + src[i..] + src[i..i + src[i..] .find("\n}\n") .or_else(|| src[i..].find("\n }\n")) .unwrap()] + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n") }; assert!(body(enc, "pub(super) fn aacs_bus_step(").contains("bus_step_guard(")); assert!(body(include_str!("mod.rs"), "fn css_bus_step(").contains("bus_step_guard(")); diff --git a/src/drive/allow.rs b/src/drive/allow.rs index ff0a7ce6..106945f3 100644 --- a/src/drive/allow.rs +++ b/src/drive/allow.rs @@ -7,6 +7,8 @@ pub(crate) const PREVENT_ALLOW: u8 = 0x1E; /// START STOP UNIT. pub(crate) const START_STOP_UNIT: u8 = 0x1B; +/// START STOP UNIT with LoEj=1, Start=0: eject the disc if permitted (MMC-6 Table 633). +pub(crate) const EJECT_CDB: [u8; 6] = [START_STOP_UNIT, 0, 0, 0, 0x02, 0]; /// REPORT KEY. pub(crate) const REPORT_KEY: u8 = 0xA4; @@ -78,7 +80,7 @@ mod tests { const ALLOW: [u8; 6] = [0x1E, 0, 0, 0, 0x00, 0]; const PREVENT: [u8; 6] = [0x1E, 0, 0, 0, 0x01, 0]; - const EJECT: [u8; 6] = [0x1B, 0, 0, 0, 0x02, 0]; + const EJECT: [u8; 6] = EJECT_CDB; const START: [u8; 6] = [0x1B, 0, 0, 0, 0x01, 0]; const READ: [u8; 10] = [0x28, 0, 0, 0, 0, 0, 0, 0, 1, 0]; @@ -132,5 +134,8 @@ mod tests { alloc[10] = 0x00; assert_eq!(invalidated_agid(&alloc), None, "format 0 allocates"); assert_eq!(invalidated_agid(&[0xA4, 0, 0]), None, "short CDB"); + let mut other = release(3); + other[0] = 0x00; + assert_eq!(invalidated_agid(&other), None, "not REPORT KEY"); } } diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 73277b27..6bbf7e39 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -70,7 +70,7 @@ pub enum DriveStatus { // SCSI opcodes used in drive control const SCSI_TEST_UNIT_READY: u8 = 0x00; -const SCSI_START_STOP_UNIT: u8 = 0x1B; +const SCSI_START_STOP_UNIT: u8 = allow::START_STOP_UNIT; /// Idle time the disc sits spun-down during [`Drive::spin_cycle`] before it's /// spun back up — long enough for the mechanism's fast-fail wedge state to /// clear. Validated at 5–6 s live. @@ -78,11 +78,11 @@ const SPIN_DOWN_IDLE_SECS: u64 = 5; /// Settle time after spin-up in [`Drive::spin_cycle`] before the caller reads /// again, so the first post-cycle read doesn't hit a transient NOT_READY. const SPIN_UP_SETTLE_SECS: u64 = 10; -const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E; +const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = allow::PREVENT_ALLOW; const SCSI_GET_EVENT_STATUS: u8 = 0x4A; const SCSI_MODE_SENSE: u8 = 0x5A; const SCSI_MODE_SELECT: u8 = 0x55; -const SCSI_REPORT_KEY: u8 = 0xA4; +const SCSI_REPORT_KEY: u8 = allow::REPORT_KEY; // SBC/MMC Read-Write Error Recovery mode page. Flipping PER makes the drive REPORT recovered // reads instead of silently returning best-effort GOOD data. @@ -597,8 +597,9 @@ impl Drive { } /// Wait for the drive to become ready: TEST UNIT READY every 500 ms until it - /// answers GOOD. Fails with `DeviceNotReady` only after 60 s without progress - /// (§2.11): an answer not yet seen in this wait, or a rising progress indicator. + /// answers GOOD. Fails with `DeviceNotReady` after 60 s without progress + /// (§2.11): an answer not yet seen in this wait, or a rising progress indicator; + /// or after an absolute 10 min ceiling, however much progress it shows. /// A Stop interrupts between polls; a dead bus fails after 5 s of failures. pub fn wait_ready(&mut self) -> Result<()> { self.wait_ready_with(WaitReadyTiming::PRODUCTION) @@ -610,6 +611,7 @@ impl Drive { let t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::drive", phase = "wait_ready", "begin"); let mut hb = crate::progress::Heartbeat::new("wait_ready"); + let ceiling_hit: bool; // T6: the answers seen so far and the highest progress indicator; either // growing re-arms the no-progress window. let moved = Progress::new(); @@ -694,7 +696,9 @@ impl Drive { } } } - if stall.poll(&moved) == crate::halt::Stall::Expired { + let stalled = stall.poll(&moved) == crate::halt::Stall::Expired; + if stalled || t0.elapsed() >= timing.ceiling { + ceiling_hit = !stalled; break; } self.pause(timing.poll)?; @@ -704,7 +708,8 @@ impl Drive { phase = "wait_ready", elapsed_ms = t0.elapsed().as_millis() as u64, window_ms = timing.window.as_millis() as u64, - "device never became ready: no progress for the whole window" + ceiling_hit, + "device never became ready: no progress for the window, or the ceiling was hit" ); Err(Error::DeviceNotReady { path: self.device_path.clone(), @@ -861,12 +866,9 @@ impl Drive { /// Initialize drive — drive-prep unlock + init. /// Optional. Adds features: removes riplock, enables UHD reads, speed control. /// - /// The drive-prep (OEM) unlock is required for BD/UHD (AACS) reads, - /// but it puts the drive in an extended-access state where stock CSS - /// authentication no longer works — so a CSS-protected DVD can't be read. - /// For a DVD we therefore SKIP the unlock and run the drive in its normal - /// stock mode; the DVD path then issues standard CSS commands, which a stock - /// drive honors. BD/UHD and any non-DVD/unknown media keep today's behavior. + /// Drive-prep runs for every disc, DVD included: drive features are + /// disc-independent, and the AACS/CSS handshakes run later, gated on disc kind. + /// A transport fault aborts init; other errors fall through to stock mode. pub fn init(&mut self) -> Result<()> { let t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::drive", phase = "init", "begin"); @@ -1110,10 +1112,16 @@ impl Drive { /// OEM VID through it (VID via the OEM path is decoupled from the host cert + /// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an /// unlocker claims this drive" — rather than the old const `false`. - pub fn is_unlocked(&self) -> bool { + pub fn has_unlocker(&self) -> bool { crate::unlock_bridge::unlocker_name(&self.drive_id).is_some() } + /// Deprecated alias of [`Self::has_unlocker`]. + #[deprecated(note = "use has_unlocker")] + pub fn is_unlocked(&self) -> bool { + self.has_unlocker() + } + /// Read sectors from the disc. Single-shot — no inline retries, no /// SCSI reset. /// @@ -1286,16 +1294,12 @@ impl Drive { { let len = count as usize * 2048; let offset = lba as i64 * 2048; - // Drop kernel cache for this region so we get - // a fresh device read, not stale page-cache - // data from a prior successful neighbour read. - let _ = unsafe { - libc::posix_fadvise(fd, offset, len as i64, libc::POSIX_FADV_DONTNEED) - }; - let n = unsafe { - libc::pread(fd, buf.as_mut_ptr() as *mut libc::c_void, len, offset) - }; + // A Stop that landed since exec's check issues no blocking read. + self.check_token()?; + let n = crate::scsi::linux::pread_uncached(fd, &mut buf[..len], offset); if n == len as isize { + // A Stop during the blocking read discards the data, as `exec` does. + self.check_token()?; tracing::info!( target: "freemkv::drive", lba, @@ -1427,7 +1431,7 @@ impl Drive { pub fn eject(&mut self) -> Result<()> { self.unlock_tray(); // SS-6 MMC-6 Table 633: LoEj 1, Start 0 = "Eject the disc if permitted". - let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0]; + let eject_cdb = allow::EJECT_CDB; let mut buf = [0u8; 0]; self.exec( &eject_cdb, @@ -1444,7 +1448,7 @@ impl Drive { pub(crate) fn finish_eject(&mut self) -> Result<()> { self.unlock_tray(); // SS-6 MMC-6 Table 633: LoEj 1, Start 0 = "Eject the disc if permitted". - let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0]; + let eject_cdb = allow::EJECT_CDB; let mut buf = [0u8; 0]; let dir = crate::scsi::DataDirection::None; self.exec_cleanup(&eject_cdb, dir, &mut buf, 30_000, CleanupCtx::FinishEject)?; @@ -1506,7 +1510,7 @@ impl Drop for Drive { // SgIoTransport::drop() runs next, calling libc::close(fd) #[cfg(target_os = "linux")] if let Some(fd) = self.block_dev_fd.take() { - unsafe { libc::close(fd) }; + crate::scsi::linux::close_block_fd(fd); } } } @@ -1526,14 +1530,7 @@ fn open_block_device_for_sg(sg_path: &Path) -> Option .find_map(|e| e.file_name().into_string().ok())?; let block_path = format!("/dev/{}", block_name); - let mut bytes = block_path.as_bytes().to_vec(); - bytes.push(0); - let fd = unsafe { - libc::open( - bytes.as_ptr() as *const libc::c_char, - libc::O_RDONLY | libc::O_CLOEXEC, - ) - }; + let fd = crate::scsi::linux::open_block_ro(&block_path); if fd < 0 { tracing::debug!( target: "freemkv::drive", @@ -1718,6 +1715,9 @@ const WAIT_READY_MAX_EMPTY_POLLS: u32 = 10; // first one's completion, before wait_ready calls the bus dead. const WAIT_READY_DEAD_BUS_BUDGET: Duration = Duration::from_secs(5); +// Absolute wait_ready ceiling: a drive cycling fresh answers can't re-arm the window forever. +const WAIT_READY_CEILING: Duration = Duration::from_secs(600); + /// `wait_ready`'s durations (§2.11, T3/T5/T6), parameters so tests run in ms. #[derive(Debug, Clone, Copy)] pub(crate) struct WaitReadyTiming { @@ -1727,6 +1727,8 @@ pub(crate) struct WaitReadyTiming { pub window: Duration, /// T5: an unbroken run of transport failures this long is a dead bus. pub dead_bus: Duration, + /// Absolute cap on the whole wait, however the drive's answers vary. + pub ceiling: Duration, } impl WaitReadyTiming { @@ -1735,6 +1737,7 @@ impl WaitReadyTiming { poll: Duration::from_millis(500), window: Duration::from_secs(60), dead_bus: WAIT_READY_DEAD_BUS_BUDGET, + ceiling: WAIT_READY_CEILING, }; } @@ -3101,6 +3104,18 @@ mod command_tests { ); } + // An event length too short to hold a Media Event Descriptor means byte 5 is not a + // Media Status, even with NEA clear and class Media: fall back to TUR. + #[test] + fn drive_status_rejects_a_too_short_descriptor_length() { + for len in [0u16, 2, 5] { + let mut reply = media_event_reply(0x00); + reply[0..2].copy_from_slice(&len.to_be_bytes()); + let mut d = drive_with(reply); + assert_eq!(d.drive_status(), DriveStatus::DiscPresent, "len {len}"); + } + } + #[test] fn drive_status_short_transfer_falls_back_to_tur() { // bytes_transferred < 6 (buffer len 8, payload 4) means the GET EVENT reply @@ -3318,6 +3333,7 @@ mod command_tests { poll: std::time::Duration::from_millis(10), window: std::time::Duration::from_millis(300), dead_bus: WAIT_READY_DEAD_BUS_BUDGET, + ceiling: std::time::Duration::from_secs(600), }); assert!( matches!(r, Err(Error::DeviceNotReady { .. })), @@ -3325,6 +3341,48 @@ mod command_tests { ); } + // A drive that keeps answering with a fresh sense triple every poll re-arms the + // no-progress window forever; the absolute ceiling must still end the wait. + #[test] + fn wait_ready_ceiling_bounds_a_cycling_drive() { + struct Cycling(u16); + impl ScsiTransport for Cycling { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + self.0 = self.0.wrapping_add(1); + let mut sense = [0u8; 32]; + sense[0] = 0x70; + sense[2] = 0x02; + sense[12] = (self.0 >> 8) as u8; + sense[13] = self.0 as u8; + Err(Error::ScsiError { + opcode: cdb[0], + status: 2, + sense: Some(crate::scsi::ScsiSense { + sense_key: 2, + asc: sense[12], + ascq: sense[13], + }), + }) + } + } + let mut d = Drive::from_transport_for_test(Box::new(Cycling(0x0500))); + let t0 = std::time::Instant::now(); + let r = d.wait_ready_with(WaitReadyTiming { + poll: std::time::Duration::from_millis(5), + window: std::time::Duration::from_secs(30), + dead_bus: WAIT_READY_DEAD_BUS_BUDGET, + ceiling: std::time::Duration::from_millis(300), + }); + assert!(matches!(r, Err(Error::DeviceNotReady { .. })), "{r:?}"); + assert!(t0.elapsed() < std::time::Duration::from_secs(5)); + } + // A dead bus (transport failures in a row) will never spin up, so wait_ready // must surface it once the ~5 s budget is spent, not poll a phantom for ~30 s. #[test] @@ -4034,20 +4092,67 @@ mod command_tests { } } + /// Logs every CDB (unlike `RecordingTransport`, which keeps the last) and answers + /// each command with `payload`. + struct LogTransport { + log: Arc>>>, + payload: Vec, + } + impl ScsiTransport for LogTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + self.log.lock().unwrap().push(cdb.to_vec()); + let n = self.payload.len().min(data.len()); + data[..n].copy_from_slice(&self.payload[..n]); + Ok(ScsiResult { + status: 0, + bytes_transferred: n, + sense: [0u8; 32], + }) + } + } + + fn logging(payload: Vec) -> (Drive, Arc>>>) { + let log = Arc::new(Mutex::new(Vec::new())); + let t = LogTransport { + log: log.clone(), + payload, + }; + (Drive::from_transport_for_test(Box::new(t)), log) + } + #[test] fn eject_unlocks_then_sends_start_stop_with_loej() { - let RecordingHarness { - drive: mut d, - cdb, - timeouts: _to, - } = recording(TransportOutcome::Ok(0)); + let (mut d, log) = logging(Vec::new()); d.eject().unwrap(); - // The mock only records the LAST cdb; eject's own START STOP UNIT - // (with LOEJ=1, byte 4 == 0x02) must be what's left recorded, not - // the PREVENT/ALLOW from unlock_tray it calls first. - let c = cdb.lock().unwrap(); - assert_eq!(c[0], SCSI_START_STOP_UNIT); - assert_eq!(c[4], 0x02, "START=0, LOEJ=1 -> eject"); + let log = log.lock().unwrap(); + let first = &log[0]; + assert_eq!(first[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, "unlock first"); + assert_eq!(first[4], 0x00, "ALLOW"); + let last = log.last().unwrap(); + assert_eq!(last[0], SCSI_START_STOP_UNIT); + assert_eq!(last[4], 0x02, "START=0, LOEJ=1 -> eject"); + } + + #[test] + fn enable_recovered_error_reporting_sends_mode_select10_with_payload_length() { + // 8-byte MODE(10) header, no block descriptor, 12-byte error-recovery page. + let mut sense = vec![0u8; 20]; + sense[1] = 18; // mode data length + sense[8] = MODE_PAGE_ERROR_RECOVERY; + sense[9] = 0x0A; + let (mut d, log) = logging(sense); + assert!(d.enable_recovered_error_reporting()); + let log = log.lock().unwrap(); + let c = log.last().unwrap(); + assert_eq!(c[0], SCSI_MODE_SELECT); + assert_eq!(c[1], 0x10, "PF=1, SP=0"); + assert_eq!(&c[7..9], &[0x00, 20], "parameter list length"); } /// `SectorSource for Drive` must actually forward to `Drive`'s own diff --git a/src/drive/stop_tests.rs b/src/drive/stop_tests.rs index d5670a9e..382467a6 100644 --- a/src/drive/stop_tests.rs +++ b/src/drive/stop_tests.rs @@ -84,6 +84,7 @@ fn timing(window: Duration, dead_bus: Duration) -> WaitReadyTiming { poll: (window / 120).min(MS(10)), window, dead_bus, + ceiling: Duration::from_secs(600), } } @@ -148,6 +149,30 @@ fn no_raw_execute_outside_dispatch() { ); } +/// LD2 (structural): the transport field is touched only by its three known uses in +/// drive/mod.rs (dispatch, sense progress, max transfer) and by no sibling submodule. +#[test] +fn transport_field_is_not_reachable_outside_dispatch() { + let count = |src: &str, cut: &str| { + let end = src.find(cut).unwrap_or(src.len()); + src[..end].matches("self.scsi.").count() + }; + let cut = "#[cfg(test)]\nmod halt_tests"; + assert_eq!(count(include_str!("mod.rs"), cut), 3, "mod.rs"); + for (name, src) in [ + ("allow.rs", include_str!("allow.rs")), + ("linux.rs", include_str!("linux.rs")), + ("macos.rs", include_str!("macos.rs")), + ("windows.rs", include_str!("windows.rs")), + ] { + assert_eq!( + count(src, "#[cfg(test)]"), + 0, + "{name} must go through Drive::exec" + ); + } +} + /// LD3: a READ that completes after a cancel is discarded: `Halted`, not good data. #[test] fn read_postcheck_discards_after_cancel() { diff --git a/src/dvdnav/nav.rs b/src/dvdnav/nav.rs index 1f035084..164bb70b 100644 --- a/src/dvdnav/nav.rs +++ b/src/dvdnav/nav.rs @@ -38,6 +38,28 @@ const STEP_BUDGET: usize = 1024; /// untrusted u16, so it is clamped to this format maximum. const MAX_CMDS: usize = 128; +// Compare ops (libdvdnav eval_compare). +const CMP_AND: u8 = 1; +const CMP_EQ: u8 = 2; +const CMP_NE: u8 = 3; +const CMP_GE: u8 = 4; +const CMP_GT: u8 = 5; +const CMP_LE: u8 = 6; +const CMP_LT: u8 = 7; + +// Set-op codes (libdvdnav eval_set_op). +const SET_MOV: u8 = 1; +const SET_SWAP: u8 = 2; +const SET_ADD: u8 = 3; +const SET_SUB: u8 = 4; +const SET_MUL: u8 = 5; +const SET_DIV: u8 = 6; +const SET_MOD: u8 = 7; +const SET_RND: u8 = 8; +const SET_AND: u8 = 9; +const SET_OR: u8 = 10; +const SET_XOR: u8 = 11; + /// Maximum TT_SRPT entries honoured — the DVD-Video 99-title format maximum /// (the on-disc count is an untrusted u16). Shared with the IFO parser so the /// two can never diverge. @@ -144,13 +166,13 @@ impl Vm { self.reg(c.rhs_reg) }; let result = match c.op { - 1 => (l & r) != 0, - 2 => l == r, - 3 => l != r, - 4 => l >= r, - 5 => l > r, - 6 => l <= r, - 7 => l < r, + CMP_AND => (l & r) != 0, + CMP_EQ => l == r, + CMP_NE => l != r, + CMP_GE => l >= r, + CMP_GT => l > r, + CMP_LE => l <= r, + CMP_LT => l < r, _ => false, }; (result, tainted) @@ -172,23 +194,23 @@ impl Vm { // Each arm sets (new value, new taint). `mov` overwrites with the source's // taint alone (clearing prior taint); accumulating ops union both. let (nv, nt) = match op { - 1 => (v, src_tainted), // mov - 2 => { + SET_MOV => (v, src_tainted), // mov + SET_SWAP => { // swap: reg2 (byte5 low nibble) takes the old value first. let reg2 = (src & 0x0F) as usize; self.gprm[reg2] = cur; self.gprm_tainted[reg2] = cur_tainted; (v, src_tainted) } - 3 => (cur.saturating_add(v), t), - 4 => (cur.saturating_sub(v), t), - 5 => (cur.saturating_mul(v), t), // libdvdnav's i32 product overflows (C UB) past 0x7FFF_FFFF - 6 => (cur.checked_div(v).unwrap_or(0xFFFF), t), - 7 => (cur.checked_rem(v).unwrap_or(0xFFFF), t), - 8 => (cur, true), // rnd: non-deterministic - 9 => (cur & v, t), - 10 => (cur | v, t), - 11 => (cur ^ v, t), + SET_ADD => (cur.saturating_add(v), t), + SET_SUB => (cur.saturating_sub(v), t), + SET_MUL => (cur.saturating_mul(v), t), // libdvdnav's i32 product overflows (C UB) past 0x7FFF_FFFF + SET_DIV => (cur.checked_div(v).unwrap_or(0xFFFF), t), + SET_MOD => (cur.checked_rem(v).unwrap_or(0xFFFF), t), + SET_RND => (cur, true), // rnd: non-deterministic + SET_AND => (cur & v, t), + SET_OR => (cur | v, t), + SET_XOR => (cur ^ v, t), _ => (cur, self.gprm_tainted[idx]), // unknown op: no-op }; self.gprm[idx] = nv; @@ -526,68 +548,49 @@ mod tests { // swp g0,g1 moves g1 into g0 and the old g0 into g1 (libdvdnav eval_set_op case 2). #[test] fn swap_exchanges_both_registers() { - // g1 = 2; g2 = 2; swp g0,g1; if g0 == g2 JumpTT 2; JumpTT 1. - let vmgi = build_vmgi( - &[ - h("7100000100020000"), - h("7100000200020000"), - h("6200000000010000"), - h("3022000000020002"), - h("3002000000010000"), - ], - 1, - &[(2, 1), (3, 1)], - ); - assert_eq!(resolve_from_vmg(&vmgi).map(|r| r.title), Some(2)); + let mut vm = Vm::new(); + vm.gprm[0] = 5; + vm.gprm[1] = 9; + vm.gprm_tainted[0] = true; + vm.set(0, SET_SWAP, false, 0, 1); + assert_eq!((vm.gprm[0], vm.gprm[1]), (9, 5), "both registers written"); + assert!(vm.gprm_tainted[1], "the source takes the old taint"); + assert!(!vm.gprm_tainted[0]); } - // rnd is non-deterministic: a branch on its result must abstain. + // Every compare op decides on lr (libdvdnav eval_compare). #[test] - fn branch_on_rnd_result_abstains() { - // g0 = rnd 3; if g0 == g1 JumpTT 2; JumpTT 3. - let vmgi = build_vmgi( - &[ - h("7800000000030000"), - h("3022000000020001"), - h("3002000000030000"), - ], - 1, - &[(2, 1), (3, 1), (4, 1)], - ); - assert_eq!(resolve_from_vmg(&vmgi), None); - } - - // SetSystem op 3 (SetGPRMMD) stores into a GPRM (libdvdnav eval_system_set case 3). - #[test] - fn setgprmmd_writes_the_gprm() { - // SetGPRMMD g0 = 2; g1 = 2; if g0 == g1 JumpTT 3; JumpTT 1. - let vmgi = build_vmgi( - &[ - h("5300000200000000"), - h("7100000100020000"), - h("3022000000030001"), - h("3002000000010000"), - ], - 1, - &[(2, 1), (3, 1), (4, 1)], - ); - assert_eq!(resolve_from_vmg(&vmgi).map(|r| r.title), Some(3)); - } - - // A counter-mode GPRM counts elapsed time: a branch on it must abstain. - #[test] - fn counter_mode_gprm_taints_later_compares() { - // SetGPRMMD g0 = 0 (counter mode, byte5 bit 7); if g0 == g1 JumpTT 3; JumpTT 1. - let vmgi = build_vmgi( - &[ - h("5300000000800000"), - h("3022000000030001"), - h("3002000000010000"), - ], - 1, - &[(2, 1), (3, 1), (4, 1)], - ); - assert_eq!(resolve_from_vmg(&vmgi), None); + fn compare_ops_decide_on_ordering() { + let mut vm = Vm::new(); + vm.gprm[0] = 5; + let ev = |vm: &Vm, op, imm| { + let c = Compare { + op, + lhs_reg: 0, + immediate: true, + imm, + rhs_reg: 0, + }; + vm.eval(&c).0 + }; + // (op, result for imm 4 [l>r], 5 [l==r], 6 [l Command { _ => Instr::Nop, }, TYPE_SPECIAL => match cmd { - SP_GOTO => Instr::Goto { line: b[7] }, + // No parental model: SPRM13 is not tracked, only the goto is followed. + SP_GOTO | SP_SET_TMP_PML => Instr::Goto { line: b[7] }, SP_BREAK => Instr::Break, _ => Instr::Nop, }, @@ -327,6 +330,20 @@ mod tests { v.try_into().unwrap() } + // A set's link flag covers sub-ops 0..=LINKSUB_MAX inclusive; above it, no link. + #[test] + fn set_link_flag_boundary_at_linksub_max() { + assert!(decode(&h("7101000000010010")).link, "sub-op 0x10 is a link"); + assert!(!decode(&h("7101000000010011")).link, "sub-op 0x11 is not"); + } + + // Special sub-command 3 (SetTmpPML) sets SPRM13 then gotos byte7 when cond holds. + #[test] + fn set_tmp_pml_decodes_as_goto() { + let c = decode(&h("0003000000000005")); + assert_eq!(c.instr, Instr::Goto { line: 5 }); + } + // KATs taken from real-world First-Play command streams. #[test] fn first_play_jumptt_1_decodes() { diff --git a/src/error.rs b/src/error.rs index be9051b2..507253e6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -70,6 +70,9 @@ pub const E_BUS_STREAM_UNMAPPED: u16 = 6021; /// whole-disc image (`iso://` copy, `dir://` extract): only the chosen titles, nav /// and UDF were ever read, so the rest of it is not disc data. pub const E_IMAGE_SCOPED: u16 = 6022; +/// An HD-DVD `ADV_OBJ/VPLST*.XPL` playlist exceeds the parser's size cap. Logged by +/// `disc::hddvd`, which then falls back to the per-clip heuristic; no [`Error`] variant. +pub const E_XPL_TOO_LARGE: u16 = 6023; // AACS (7xxx) pub const E_AACS_NO_KEYS: u16 = 7000; @@ -138,6 +141,15 @@ pub const E_AACS_NO_USABLE_HOST_CERT: u16 = 7033; /// carries a `vidfp`: the key is derivable only from the disc's Volume ID. /// "the server must tell the two apart by code" (keys-upfront-design J11). pub const E_AACS_VID_NEEDS_DISC: u16 = 7034; +// AACS 2.1 variant media-key chain (`MediaKeyVariantError`); 7105 is a reserved gap. +pub const E_MKB_VARIANT_NOT_VARIANT: u16 = 7100; +pub const E_MKB_VARIANT_INCOMPLETE: u16 = 7101; +pub const E_MKB_VARIANT_PK_UNAVAILABLE: u16 = 7102; +pub const E_MKB_VARIANT_SOFT_CORRECTION: u16 = 7103; +pub const E_MKB_VARIANT_ONLINE_CHALLENGE: u16 = 7104; +pub const E_MKB_VARIANT_TABLE_UNAVAILABLE: u16 = 7106; +pub const E_MKB_VARIANT_VKD_RANGE: u16 = 7107; +pub const E_MKB_VARIANT_VERIFY_FAILED: u16 = 7108; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; @@ -197,7 +209,7 @@ pub const E_DIR_IMAGE_SSIF_UNSUPPORTED: u16 = 9061; /// A `dir://` SOURCE `VIDEO_TS` folder's IFO-declared VOB offsets cannot be /// satisfied by any placement: the required start sector of a VOB lies BELOW /// the end of the file that must precede it. Carries the offending file's disc -/// path. A silently misplaced VOB would rip the wrong sectors. +/// path. Also raised for an IFO too short to resolve its placement offsets. pub const E_DIR_IMAGE_PLACEMENT: u16 = 9062; /// A `dir://` SOURCE folder still carries live AACS-encrypted content: it has /// an `AACS/` directory AND the sampled content units are genuinely scrambled. @@ -217,7 +229,7 @@ pub const E_DIR_IMAGE_TOO_LARGE: u16 = 9066; /// File Identifier Descriptor stores the encoded length in one byte. pub const E_DIR_NAME_TOO_LONG: u16 = 9067; /// One directory in the folder holds more subdirectories than a UDF link count -/// can express (it is 16 bits, one per child plus one for its own entry). +/// can express (16 bits), or more entries than the reader's directory size cap. pub const E_DIR_IMAGE_FANOUT: u16 = 9068; /// A title's clip marks excluded more frames than they kept. pub const E_SEAM_PLAN_DROPPED_MOST: u16 = 9069; @@ -303,6 +315,8 @@ pub const E_TIMED_OUT: u16 = 9073; /// `mpg://` output but the title has no video track a program stream can carry /// (MPEG-1/2, H.264, HEVC or VC-1). The `mpg://` twin of [`E_MP4_NO_VIDEO_TRACK`]. pub const E_MPG_NO_VIDEO_TRACK: u16 = 9074; +/// `mpg://` reached end of input with access units no pack could take. +pub const E_MPG_UNPACKETIZED: u16 = 9075; // ── Error enum ────────────────────────────────────────────────────────────── @@ -883,7 +897,8 @@ pub enum Error { DirNameTooLong { path: String, }, - /// One directory holds more subdirectories than a UDF link count can express. + /// One directory holds more subdirectories than a UDF link count can express, + /// or more entries than the reader's per-directory size cap allows. /// /// A directory's File Entry records its link count in 16 bits, and that /// count is one per child directory plus one for its own entry in its @@ -919,6 +934,8 @@ pub enum Error { /// `mpg://` target title has no video track a program stream can carry. See /// [`E_MPG_NO_VIDEO_TRACK`]. Declared ahead of the `mpg://` sink that raises it. MpgNoVideoTrack, + /// `mpg://` end of input left access units unwritten. See [`E_MPG_UNPACKETIZED`]. + MpgUnpacketized, } impl Error { @@ -1060,6 +1077,7 @@ impl Error { Error::StreamHeaderWritten => E_STREAM_HEADER_WRITTEN, Error::TimedOut { .. } => E_TIMED_OUT, Error::MpgNoVideoTrack => E_MPG_NO_VIDEO_TRACK, + Error::MpgUnpacketized => E_MPG_UNPACKETIZED, Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED, Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE, } @@ -1188,7 +1206,10 @@ impl std::fmt::Display for Error { required, available, } => write!(f, "E{}: {}/{}", self.code(), required, available), - Error::DirNameCollision { host } => write!(f, "E{}: {}", self.code(), host), + // `host` is a raw disc name: escape control characters. + Error::DirNameCollision { host } => { + write!(f, "E{}: {}", self.code(), host.escape_debug()) + } // errno is Option: emit it after the code when present, else the // bare code (mirrors NoDiscKey's empty-field handling — no dangling // "colon space" suffix). @@ -1338,6 +1359,7 @@ impl From for std::io::Error { // video track / missing codec-private config. All are invalid data. E_MP4_NO_VIDEO_TRACK | E_MPG_NO_VIDEO_TRACK + | E_MPG_UNPACKETIZED | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE | E_MP4_UNKNOWN_RESOLUTION => std::io::ErrorKind::InvalidData, @@ -1398,6 +1420,10 @@ pub type Result = std::result::Result; /// [`From for io::Error`] is the ONLY path from a typed [`Error`] to an `io::Error` in /// this crate. pub fn error_code(e: &std::io::Error) -> Option { + // Typed payload first: exact, and immune to a foreign message that looks like a code. + if let Some(typed) = e.get_ref().and_then(|i| i.downcast_ref::()) { + return Some(typed.code()); + } // Round-tripped: `From for io::Error` renders as "E[: …]". let s = e.to_string(); let digits = s.strip_prefix('E')?; @@ -1619,6 +1645,55 @@ mod tests { assert!(!is_skippable_title_stub(&aacs)); } + // A stop maps to Interrupted, not the 6xxx InvalidData arm it sits inside. + #[test] + fn halted_maps_to_interrupted() { + let e: std::io::Error = Error::Halted.into(); + assert_eq!(e.kind(), std::io::ErrorKind::Interrupted); + assert!(is_halt(&e)); + } + + // Every whole-disc key code, and only those, is disc-level. + #[test] + fn disc_level_no_key_covers_every_documented_code() { + for e in [ + Error::KeydbLoad { path: "p".into() }, + Error::AacsNoKeys, + Error::KeyServiceUnavailable, + Error::KeyServiceUnauthorized, + Error::KeyServiceRateLimited, + ] { + let io: std::io::Error = e.into(); + assert!(is_disc_level_no_key(&io), "{io}"); + } + let per_title: std::io::Error = Error::MkvInvalid.into(); + assert!(!is_disc_level_no_key(&per_title)); + } + + // Pin, not a guard: typed and string paths agree for every `Error` (Display leads + // with its code). The fallback stays for consumers' string-coded io::Errors. + #[test] + fn error_code_reads_the_typed_payload() { + let typed: std::io::Error = Error::Halted.into(); + assert_eq!(error_code(&typed), Some(E_HALTED)); + assert_eq!( + error_code(&std::io::Error::other("E6010: x")), + Some(E_HALTED) + ); + assert_eq!(error_code(&std::io::Error::other("plain")), None); + } + + // A raw disc name with control bytes cannot inject terminal escapes via Display. + #[test] + fn dir_name_collision_display_escapes_control_characters() { + let e = Error::DirNameCollision { + host: "A\x1b[2JB\n".into(), + }; + let shown = e.to_string(); + assert!(!shown.chars().any(char::is_control), "{shown:?}"); + assert!(shown.contains("A\\u{1b}[2JB\\n"), "{shown}"); + } + #[test] fn new_variants_have_distinct_codes() { let codes = [ @@ -1676,6 +1751,7 @@ mod tests { Error::AacsVidNeedsDisc.code(), Error::TimedOut { op: "verify" }.code(), Error::MpgNoVideoTrack.code(), + Error::MpgUnpacketized.code(), Error::ShortImageRead { lba: 0, expected: 1, @@ -1767,6 +1843,7 @@ mod tests { (Error::AacsVidNeedsDisc, E_AACS_VID_NEEDS_DISC), (Error::TimedOut { op: "verify" }, E_TIMED_OUT), (Error::MpgNoVideoTrack, E_MPG_NO_VIDEO_TRACK), + (Error::MpgUnpacketized, E_MPG_UNPACKETIZED), ( Error::BusStreamUnmapped { files: "/BDMV/STREAM/00002.m2ts".into(), @@ -2091,6 +2168,7 @@ mod tests { assert!((6000..7000).contains(&E_IMAGE_ENDS_BEFORE_READ)); assert!((6000..7000).contains(&E_BUS_STREAM_UNMAPPED)); assert!((6000..7000).contains(&E_IMAGE_SCOPED)); + assert!((6000..7000).contains(&E_XPL_TOO_LARGE)); // AACS (7xxx) assert!((7000..8000).contains(&E_AACS_NO_KEYS)); assert!((7000..8000).contains(&E_NO_DISC_KEY)); @@ -2138,6 +2216,7 @@ mod tests { (Error::StreamHeaderWritten, E_STREAM_HEADER_WRITTEN), (Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK), (Error::MpgNoVideoTrack, E_MPG_NO_VIDEO_TRACK), + (Error::MpgUnpacketized, E_MPG_UNPACKETIZED), (Error::Mp4Invalid, E_MP4_INVALID), (Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE), (Error::Mp4UnknownResolution, E_MP4_UNKNOWN_RESOLUTION), diff --git a/src/halt.rs b/src/halt.rs index f7771e89..71578340 100644 --- a/src/halt.rs +++ b/src/halt.rs @@ -280,7 +280,7 @@ fn backoff_poll( d: Duration, mut attempt: impl FnMut(T) -> std::result::Result<(), SendFail>, ) -> std::result::Result<(), SendFail> { - let end = Instant::now() + d; + let end = Instant::now().checked_add(d); let mut pending = v; let mut backoff = Duration::from_millis(1); loop { @@ -288,7 +288,7 @@ fn backoff_poll( Err(SendFail::Timeout(back)) => pending = back, done => return done, } - let left = end.saturating_duration_since(Instant::now()); + let left = end.map_or(WAIT_SLICE, |e| e.saturating_duration_since(Instant::now())); if left.is_zero() { return Err(SendFail::Timeout(pending)); } @@ -383,8 +383,10 @@ impl Progress { } fn busy_state(&self) -> (bool, u64) { - let epoch = self.0.busy_epoch.load(Ordering::Relaxed); - (self.0.busy.load(Ordering::Relaxed) > 0, epoch) + // Read `busy` first: a guard bumps the epoch before it decrements, so an + // observed decrement implies the bump is visible to the epoch load. + let busy = self.0.busy.load(Ordering::Acquire) > 0; + (busy, self.0.busy_epoch.load(Ordering::Acquire)) } } @@ -395,8 +397,8 @@ pub struct BusyGuard(Progress); impl Drop for BusyGuard { fn drop(&mut self) { - self.0.0.busy.fetch_sub(1, Ordering::Relaxed); self.0.0.busy_epoch.fetch_add(1, Ordering::Relaxed); + self.0.0.busy.fetch_sub(1, Ordering::Release); } } @@ -589,7 +591,9 @@ impl DriveHolder { /// Join the thread: its result, or its panic payload. pub fn join(mut self) -> thread::Result { - let h = self.handle.take().expect("DriveHolder joined twice"); + let Some(h) = self.handle.take() else { + return Err(Box::new("DriveHolder joined twice")); + }; if h.is_finished() { return join_finished(h); } diff --git a/src/halt/tests.rs b/src/halt/tests.rs index 2d1b31df..858ab83e 100644 --- a/src/halt/tests.rs +++ b/src/halt/tests.rs @@ -287,6 +287,14 @@ fn sync_sender_poll_backs_off_to_a_slice() { )); } +/// An unbounded `send_slice` deadline does not overflow `Instant`. +#[test] +fn send_slice_with_max_duration_does_not_panic() { + let (tx, rx) = mpsc::sync_channel::(1); + assert!(tx.send_slice(1, Duration::MAX).is_ok()); + assert_eq!(rx.recv(), Ok(1)); +} + // A thread blocked until the returned sender sends or drops. fn parked_thread() -> (JoinHandle, mpsc::Sender<()>) { let (tx, rx) = mpsc::channel::<()>(); @@ -338,23 +346,23 @@ fn join_within_finished_cancelled_and_expired() { )); } -/// LT5a (HR1): slow but progressing — a bump every 0.5 × window for 4 windows — +/// LT5a (HR1): slow but progressing — a bump every 0.2 × window for 4 windows — /// never expires. #[test] fn stall_timer_rearms_on_progress() { let p = Progress::new(); let mut t = StallTimer::new(WINDOW, &p); let start = Instant::now(); - let mut next_bump = start + WINDOW / 2; + let mut next_bump = start + WINDOW / 5; while start.elapsed() < WINDOW * 4 { if Instant::now() >= next_bump { p.bump(); - next_bump += WINDOW / 2; + next_bump += WINDOW / 5; } assert_ne!(t.poll(&p), Stall::Expired, "at {:?}", start.elapsed()); std::thread::sleep(Duration::from_millis(5)); } - assert!(p.get() >= 6); + assert!(p.get() >= 12); } /// LT5b (HR1): no progress → `StalledFor` until the window, then `Expired` within diff --git a/src/harness.rs b/src/harness.rs index a67aa370..0142b088 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -214,7 +214,8 @@ fn jar_class_walk_never_panics() { // the zip open nor the class walk may panic. Magic: `PK\x03\x04`. sweep("jar", &[0x50, 0x4B, 0x03, 0x04], |b| { if let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(b.to_vec())) { - crate::labels::jar::for_each_class(&mut archive, |_name, _class| {}); + let mut budget = crate::labels::jar::PARSE_INFLATE_BUDGET; + crate::labels::jar::for_each_class_budgeted(&mut archive, &mut budget, |_, _| {}); } }); } @@ -248,19 +249,35 @@ fn fox_dcx_labels_never_panics() { #[test] fn bdnav_vm_resolve_never_panics() { - // The HDMV nav VM executes an untrusted MovieObject.bdmv command list. - // Parse sweep bytes as a MovieObject set and run the VM from First-Play — - // the combined parse+execute path the per-parser index/mobj sweeps miss. + // The HDMV nav VM executes an untrusted MovieObject.bdmv command list. Random bytes + // almost never form a parseable MovieObject (num_cmds*12 overshoots the buffer), so + // each case is ALSO wrapped as the command list of a valid object to reach the VM. use crate::bdnav::index::{Index, PlaybackObj}; + let index = Index { + first_play: PlaybackObj::Hdmv { id_ref: 0 }, + top_menu: PlaybackObj::Hdmv { id_ref: 0 }, + titles: Vec::new(), + }; sweep("bdnav_vm", b"MOBJ", |b| { if let Some(mobjs) = crate::bdnav::mobj::parse(b) { - let index = Index { - first_play: PlaybackObj::Hdmv { id_ref: 0 }, - top_menu: PlaybackObj::Hdmv { id_ref: 0 }, - titles: Vec::new(), - }; let _ = crate::bdnav::vm::resolve(&index, &mobjs, &|_| false); } + let cmds: Vec<[u8; 12]> = b.as_chunks::<12>().0.iter().take(64).copied().collect(); + let wrapped = crate::bdnav::mobj::tests::build(&[&cmds]); + let mobjs = crate::bdnav::mobj::parse(&wrapped).expect("wrapped object parses"); + let _ = crate::bdnav::vm::resolve(&index, &mobjs, &|_| false); + }); +} + +#[test] +fn ifo_table_parsers_never_panic() { + // TT_SRPT, PGCIT (with PTT_SRPT) and PGC parse untrusted IFO bytes; the sweep + // aims each at a random in-buffer offset as well as offset 0. + sweep("ifo", b"DVDVIDEO", |b| { + let off = b.get(8).map_or(0, |&o| o as usize * 16); + let _ = crate::ifo::parse_tt_srpt(b, off); + let _ = crate::ifo::parse_pgcit(b, off, off / 2, &[(5, 1), (3, 2), (1, 255)]); + let _ = crate::ifo::parse_pgc(b, off, 3); }); } diff --git a/src/identity.rs b/src/identity.rs index 2e04df4c..b5849d84 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -172,16 +172,23 @@ impl std::fmt::Display for DriveId { // A GET CONFIGURATION text field: trims spaces and the NUL padding `trim` keeps. fn gc_text(field: &[u8]) -> String { - String::from_utf8_lossy(field) - .trim_matches(|c: char| c.is_whitespace() || c == '\0') - .to_string() + let text = String::from_utf8_lossy(field); + printable(text.trim_matches(|c: char| c.is_whitespace() || c == '\0')) +} + +// Drive-supplied text is untrusted: control characters (bar NUL padding) become `?` +// so they cannot reach a terminal or log through `Display`. +fn printable(s: &str) -> String { + s.chars() + .map(|c| if c.is_control() && c != '\0' { '?' } else { c }) + .collect() } /// Extract an ASCII string field from raw SCSI data. fn ascii_field(data: &[u8], start: usize, end: usize) -> String { if data.len() > start { let e = end.min(data.len()); - String::from_utf8_lossy(&data[start..e]).to_string() + printable(&String::from_utf8_lossy(&data[start..e])) } else { String::new() } @@ -232,6 +239,86 @@ mod tests { assert_eq!(id.raw_gc_010c.len(), 256); } + // Answers INQUIRY with `inq` (reporting `inq_count` bytes) and fails the GET + // CONFIGURATION whose feature byte is `halt_feature` with `Halted`. + struct ScriptTransport { + inq: Vec, + inq_count: usize, + halt_feature: Option, + } + + impl ScsiTransport for ScriptTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + buf: &mut [u8], + _timeout_ms: u32, + ) -> Result { + if cdb[0] == 0x46 && Some(cdb[3]) == self.halt_feature { + return Err(crate::error::Error::Halted); + } + let mut n = 0; + if cdb[0] == 0x12 { + n = self.inq.len().min(buf.len()); + buf[..n].copy_from_slice(&self.inq[..n]); + n = self.inq_count; + } + Ok(ScsiResult { + status: 0, + bytes_transferred: n, + sense: [0u8; 32], + }) + } + } + + fn script(halt_feature: Option) -> ScriptTransport { + let mut inq = vec![0u8; 96]; + inq[0] = 0x05; + inq[8..16].copy_from_slice(b"VENDOR "); + ScriptTransport { + inq, + inq_count: 96, + halt_feature, + } + } + + // A stop during either best-effort GET CONFIGURATION probe aborts identification. + #[test] + fn from_drive_propagates_halted_from_both_gc_probes() { + for feature in [0x0C, 0x08] { + let mut t = script(Some(feature)); + let r = DriveId::from_drive(&mut t); + assert!( + matches!(r, Err(crate::error::Error::Halted)), + "feature {feature:#x}: {r:?}" + ); + } + assert!(DriveId::from_drive(&mut script(None)).is_ok()); + } + + // A lying INQUIRY transfer count is not trusted. Pin only: `truncate` past the + // length is a no-op, so the `.min` clamp is behaviour-neutral. + #[test] + fn from_drive_clamps_oversized_inquiry_count() { + let mut t = script(None); + t.inq_count = 96 + 4096; + let id = DriveId::from_drive(&mut t).expect("from_drive must not error"); + assert_eq!(id.raw_inquiry.len(), 96); + } + + // Control characters from the drive never reach the identity strings. + #[test] + fn drive_text_control_characters_are_replaced() { + let mut t = script(None); + t.inq[8..16].copy_from_slice(b"A\x1b[31mZ "); + let id = DriveId::from_drive(&mut t).expect("from_drive"); + assert_eq!(id.vendor_id, "A?[31mZ "); + assert_eq!(gc_text(b"SN\r\n1\x1b "), "SN??1?"); + // Trailing padding is trimmed before sanitising, not turned into '?'. + assert_eq!(gc_text(b"SN1\r\n\t\0 "), "SN1"); + } + // INQUIRY byte 0: low 5 bits are the peripheral device type (5 = MMC), the // high 3 the qualifier. The shared optical filter behind every find_drive(s). #[test] diff --git a/src/ifo.rs b/src/ifo.rs index d6cf07e6..d3b7e46f 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -132,6 +132,22 @@ impl CellCategory { } } +// Count of leading secondary angle-block cells (see `DvdTitle::feature_start_cell`). +fn leading_secondary_cells(cells: &[DvdCell]) -> usize { + let n = cells.len(); + let mut idx = 0; + while idx < n { + // Stop at the first cell that is genuine feature content. + if !CellCategory::decode(cells[idx].category).is_secondary_block_piece() { + break; + } + idx += 1; + } + // Never drop everything: if every leading cell looked like a secondary + // block piece (pathological/corrupt category bytes), keep all cells. + if idx >= n { 0 } else { idx } +} + impl DvdTitle { /// Index of the first cell to include in the muxed feature. /// @@ -141,23 +157,7 @@ impl DvdTitle { /// plain-feature cell; cells before it are dropped from the feature extents. Conservative: /// only ever skips a prefix, never past the last cell or to zero cells. pub fn feature_start_cell(&self) -> usize { - let n = self.cells.len(); - if n == 0 { - return 0; - } - let mut idx = 0; - while idx < n { - let cat = CellCategory::decode(self.cells[idx].category); - // Stop at the first cell that is genuine feature content. - if !cat.is_secondary_block_piece() { - break; - } - idx += 1; - } - // Never drop everything: if every leading cell looked like a secondary - // block piece (pathological/corrupt category bytes), fall back to - // keeping all cells rather than producing an empty feature. - if idx >= n { 0 } else { idx } + leading_secondary_cells(&self.cells) } /// The feature cells after the leading-cell filter ([`Self::feature_start_cell`]). @@ -469,13 +469,14 @@ pub(crate) const MAX_TT_SRPT_TITLES: usize = 99; // Parses the VMG TT_SRPT into a per-title-set map of (chapter_count, vts_title_number). Clamps // the declared entry count to MAX_TT_SRPT_TITLES and drops duplicate (vts_number, // vts_title_num) pairs. -fn parse_tt_srpt( +pub(crate) fn parse_tt_srpt( vmg_data: &[u8], tt_srpt_offset: usize, ) -> Result>> { let num_titles = be_u16(vmg_data, tt_srpt_offset)? as usize; if num_titles > MAX_TT_SRPT_TITLES { tracing::warn!( + target: "freemkv::scan", declared = num_titles, cap = MAX_TT_SRPT_TITLES, "TT_SRPT title count exceeds the DVD-Video maximum, clamping" @@ -499,7 +500,8 @@ fn parse_tt_srpt( let vts_number = byte_at(vmg_data, base + 6)?; let vts_title_num = byte_at(vmg_data, base + 7)?; - if vts_number == 0 { + // Both numbers are 1-based; a 0 title number would alias title 1's PGC. + if vts_number == 0 || vts_title_num == 0 { continue; // invalid } if !seen.insert((vts_number, vts_title_num)) { @@ -517,7 +519,7 @@ fn parse_tt_srpt( // ── VTS parser ────────────────────────────────────────────────────────────── -/// Parse VTS_XX_0.IFO for one title set. +/// Parse VTS_XX_0.IFO for one title set, falling back to its VTS_XX_0.BUP copy. /// /// `titles_info` is a list of (chapter_count, vts_title_number) from TT_SRPT. fn parse_vts( @@ -527,7 +529,34 @@ fn parse_vts( titles_info: &[(u16, u8)], ) -> Result { let path = format!("/VIDEO_TS/VTS_{vts_number:02}_0.IFO"); - let vts_data = udf.read_file(reader, &path)?; + let err = match parse_vts_file(reader, udf, &path, &path, vts_number, titles_info) { + Ok(ts) => return Ok(ts), + Err(Error::Halted) => return Err(Error::Halted), + Err(e) => e, + }; + let bup = format!("/VIDEO_TS/VTS_{vts_number:02}_0.BUP"); + match parse_vts_file(reader, udf, &bup, &path, vts_number, titles_info) { + Ok(ts) => { + tracing::warn!(target: "freemkv::scan", vts = vts_number, code = err.code(), "VTS IFO bad; using BUP"); + Ok(ts) + } + Err(Error::Halted) => Err(Error::Halted), + Err(_) => Err(err), + } +} + +// Parses the IFO bytes at `src` (IFO or BUP). Cell sectors are relative to the IFO's LBA, so +// `ifo_path` anchors the title VOBS even when the bytes come from the BUP. +fn parse_vts_file( + reader: &mut dyn SectorSource, + udf: &UdfFs, + src: &str, + ifo_path: &str, + vts_number: u8, + titles_info: &[(u16, u8)], +) -> Result { + let path = ifo_path; + let vts_data = udf.read_file(reader, src)?; // Validate VTS magic if vts_data.len() < 12 || &vts_data[0..12] != VTS_MAGIC { @@ -543,6 +572,7 @@ fn parse_vts( // spec (the VTSI management table). The offsets are constant; the sector // values they point to are per-disc. const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature) + const VTS_PTT_SRPT_OFFSET: usize = 0xC8; // VTS_PTT_SRPT sector pointer const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer // VTS_PGCIT sector pointer @@ -552,7 +582,7 @@ fn parse_vts( // are relative to (offset 0xC0/`vtsm_vobs` is the *menu* VOBS, not the movie). // It's relative to this IFO file, so rebase by the IFO's on-disc LBA (UDF FS). let vtstt_vobs = be_u32(&vts_data, VTSTT_VOBS_OFFSET)?; - let ifo_lba = udf.file_start_lba(reader, &path)?; + let ifo_lba = udf.file_start_lba(reader, path)?; let vob_start_sector = ifo_lba.saturating_add(vtstt_vobs); // Video attributes at offset 0x200 (2 bytes) @@ -590,7 +620,11 @@ fn parse_vts( let pgcit_offset = (pgcit_sector as usize) .checked_mul(SECTOR_BYTES) .ok_or(Error::IfoParse)?; - let titles = parse_pgcit(&vts_data, pgcit_offset, titles_info)?; + // PTT_SRPT maps each title number to its PGC; sector 0 means absent. + let ptt_offset = (be_u32(&vts_data, VTS_PTT_SRPT_OFFSET)? as usize) + .checked_mul(SECTOR_BYTES) + .ok_or(Error::IfoParse)?; + let titles = parse_pgcit(&vts_data, pgcit_offset, ptt_offset, titles_info)?; Ok(DvdTitleSet { vts_number, @@ -762,10 +796,31 @@ fn dvd_lang_to_iso639_2(raw: &str) -> String { // ── PGC parser ────────────────────────────────────────────────────────────── +// 0-based PGC index of title `ttn`'s first part-of-title, from VTS_PTT_SRPT at `ptt_offset` +// (count, reserved, last byte, u32 offsets to (pgcn, pgn) entries); None if absent/unusable. +fn ptt_srpt_pgc_index(data: &[u8], ptt_offset: usize, ttn: u8) -> Option { + if ptt_offset == 0 || ttn == 0 { + return None; + } + let count = be_u16(data, ptt_offset).ok()? as usize; + let last_byte = be_u32(data, ptt_offset + 4).ok()? as usize; + if ttn as usize > count { + return None; + } + let rel = be_u32(data, ptt_offset + 8 + (ttn as usize - 1) * 4).ok()? as usize; + if rel.checked_add(4)? > last_byte.checked_add(1)? { + return None; // title has no PTT entry inside the table + } + let pgcn = be_u16(data, ptt_offset.checked_add(rel)?).ok()? as usize; + pgcn.checked_sub(1) +} + /// Parse VTS_PGCIT (Program Chain Information Table) to extract titles. -fn parse_pgcit( +/// `ptt_offset` is the VTS_PTT_SRPT byte offset (0 = absent). +pub(crate) fn parse_pgcit( data: &[u8], pgcit_offset: usize, + ptt_offset: usize, titles_info: &[(u16, u8)], ) -> Result> { if pgcit_offset + 8 > data.len() { @@ -784,8 +839,11 @@ fn parse_pgcit( let mut skipped = 0usize; for &(chapter_count, vts_title_num) in titles_info { - // VTS title numbers are 1-based; map to PGC index (typically 1:1) - let pgc_index = vts_title_num.saturating_sub(1) as usize; + // TTN -> PGC goes through PTT_SRPT; without it (or when its PGCN is past the + // table) assume 1:1 (TTN is 1-based). + let pgc_index = ptt_srpt_pgc_index(data, ptt_offset, vts_title_num) + .filter(|&i| i < num_pgcs as usize) + .unwrap_or(vts_title_num.saturating_sub(1) as usize); if pgc_index >= num_pgcs as usize { skipped += 1; tracing::warn!( @@ -854,7 +912,7 @@ fn parse_pgcit( } /// Parse a single PGC (Program Chain) to extract duration and cells. -fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result { +pub(crate) fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result { // PGC needs at least 0xE8 bytes for the cell playback info offset if pgc_offset + 0xEA > data.len() { return Err(Error::IfoParse); @@ -893,18 +951,18 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result } } - // Recalculate duration from cell times if PGC-level time is zero - let duration_secs = if duration_secs == 0.0 && !cells.is_empty() && cell_playback_offset > 0 { - let cell_base = pgc_offset + cell_playback_offset; - let mut total = 0.0; - for i in 0..cells.len() { - // Cell playback info: 24 bytes per cell, BCD time at offset 4-7 - let co = cell_base + i * 24; - if co + 8 <= data.len() { - total += bcd_to_secs(&data[co + 4..co + 8]); - } - } - total + if cells.len() < num_cells { + tracing::warn!( + target: "freemkv::scan", + declared = num_cells, + kept = cells.len(), + "PGC cell table runs past the IFO; its title keeps only the leading cells" + ); + } + + // Recalculate duration from cell times if PGC-level time is zero. + let duration_secs = if duration_secs == 0.0 && !cells.is_empty() { + cells.iter().map(|c| c.duration_secs).sum() } else { duration_secs }; @@ -1183,65 +1241,6 @@ mod tests { assert!(be_u32(&data, 0).is_err()); // only 3 bytes } - #[test] - fn struct_construction() { - let cell = DvdCell { - first_sector: 100, - last_sector: 200, - category: 0, - duration_secs: 0.0, - }; - assert_eq!(cell.first_sector, 100); - assert_eq!(cell.last_sector, 200); - - let title = DvdTitle { - chapters: 5, - duration_secs: 3600.0, - cells: vec![cell.clone()], - chapter_times: Vec::new(), - palette: None, - ast_ctl: [0; 8], - spst_ctl: [0; 32], - vts_title_num: 0, - }; - assert_eq!(title.chapters, 5); - assert!((title.duration_secs - 3600.0).abs() < 0.01); - assert_eq!(title.cells.len(), 1); - - let video = DvdVideoAttr { - codec: Codec::Mpeg2, - resolution: Resolution::R480i, - aspect: DvdAspect::R16x9, - standard: TvSystem::Ntsc, - }; - assert_eq!(video.codec, Codec::Mpeg2); - - let audio = DvdAudioAttr { - codec: Codec::Ac3, - channels: 6, - sample_rate: 48000, - language: "en".to_string(), - mpeg_ext: false, - }; - assert_eq!(audio.channels, 6); - - let ts = DvdTitleSet { - vts_number: 1, - vob_start_sector: 512, - video, - audio_streams: vec![audio], - subtitle_streams: Vec::new(), - titles: vec![title], - }; - assert_eq!(ts.vts_number, 1); - assert_eq!(ts.audio_streams.len(), 1); - - let info = DvdInfo { - title_sets: vec![ts], - }; - assert_eq!(info.title_sets.len(), 1); - } - /// mpucoder PGC_SPST_CTL: "Stream number for 4:3", "for wide", "for letterbox", "for /// pan&scan" in bytes 0-3. This is per spec; do not change without a spec citation proving /// otherwise. @@ -1654,14 +1653,20 @@ mod tests { assert_eq!(attr.language, "und"); } - /// Audio sample_rate flag (b0>>3 & 0x03): 0=48kHz, 1=96kHz, else 48kHz. - /// Verify flag 2/3 fall back to 48kHz (catch-all). + /// Audio sample_rate flag (byte 1 bits 5-4): 0=48kHz, 1=96kHz, 2/3 reserved -> 48kHz. #[test] fn audio_attr_reserved_rate_defaults_48k() { - let mut data = vec![0u8; 8]; - data[0] = 0b0001_0000; // sample-rate flag (bits 4-3) = 0b10 - let attr = parse_audio_attr(&data, 0).unwrap(); - assert_eq!(attr.sample_rate, 48000); + for (b1, want) in [(0b0000_0000u8, 48000), (0b0001_0000, 96000)] { + let mut data = vec![0u8; 8]; + data[1] = b1; + assert_eq!(parse_audio_attr(&data, 0).unwrap().sample_rate, want); + } + for b1 in [0b0010_0000u8, 0b0011_0000] { + let mut data = vec![0u8; 8]; + data[1] = b1; + let attr = parse_audio_attr(&data, 0).unwrap(); + assert_eq!(attr.sample_rate, 48000, "b1={b1:#010b}"); + } } /// Subtitle language is at [offset+2..+4]. Verify a valid 2-letter code @@ -2501,18 +2506,18 @@ mod tests { let data = build_pgcit(&[pgc0, pgc1]); // vts_title_num is 1-based: title 2 → PGC index 1. - let titles = parse_pgcit(&data, 0, &[(5, 2)]).unwrap(); + let titles = parse_pgcit(&data, 0, 0, &[(5, 2)]).unwrap(); assert_eq!(titles.len(), 1); assert_eq!(titles[0].duration_secs, 22.022); assert_eq!(titles[0].cells[0].first_sector, 50); - let titles = parse_pgcit(&data, 0, &[(5, 1)]).unwrap(); + let titles = parse_pgcit(&data, 0, 0, &[(5, 1)]).unwrap(); assert_eq!(titles.len(), 1); assert_eq!(titles[0].duration_secs, 11.011); assert_eq!(titles[0].cells[0].first_sector, 0); // Both titles, in order. - let titles = parse_pgcit(&data, 0, &[(5, 1), (7, 2)]).unwrap(); + let titles = parse_pgcit(&data, 0, 0, &[(5, 1), (7, 2)]).unwrap(); assert_eq!(titles.len(), 2); assert_eq!(titles[0].duration_secs, 11.011); assert_eq!(titles[1].duration_secs, 22.022); @@ -2524,11 +2529,11 @@ mod tests { #[test] fn pgcit_header_boundary() { let data = vec![0u8; 8]; - let titles = parse_pgcit(&data, 0, &[(5, 1)]).expect("complete header parses"); + let titles = parse_pgcit(&data, 0, 0, &[(5, 1)]).expect("complete header parses"); assert!(titles.is_empty()); for len in 0..8usize { assert!( - parse_pgcit(&vec![0u8; len], 0, &[(5, 1)]).is_err(), + parse_pgcit(&vec![0u8; len], 0, 0, &[(5, 1)]).is_err(), "len={len}" ); } @@ -2543,7 +2548,7 @@ mod tests { // entry for PGC index 2 (offset 24..32) is entirely past the end. let mut data = vec![0u8; 20]; data[0..2].copy_from_slice(&3u16.to_be_bytes()); - let titles = parse_pgcit(&data, 0, &[(5, 3)]) + let titles = parse_pgcit(&data, 0, 0, &[(5, 3)]) .expect("a truncated SRP entry is skipped, not an error"); assert!(titles.is_empty()); } @@ -2673,4 +2678,302 @@ mod tests { clamped to {MAX_TT_SRPT_TITLES}, got {total}" ); } + + // ── Review-round tests: PTT_SRPT, angles, chapter rates, VMG/VTS ───── + + /// VTS_TTN is 1-based: an entry carrying title number 0 would alias title 1. + #[test] + fn tt_srpt_skips_title_number_zero() { + let vmg = vmg_with_tt_srpt(1, &[(9, 1, 0), (4, 1, 1)]); + let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse"); + assert_eq!(map[&1], vec![(4, 1)], "the title-number-0 entry is dropped"); + } + + // PTT_SRPT bytes: header, one u32 offset per title, then one (pgcn, pgn) entry per title. + fn build_ptt_srpt(pgcn_per_title: &[u16]) -> Vec { + let n = pgcn_per_title.len(); + let total = 8 + n * 4 + n * 4; + let mut d = vec![0u8; total]; + d[0..2].copy_from_slice(&(n as u16).to_be_bytes()); + d[4..8].copy_from_slice(&((total - 1) as u32).to_be_bytes()); + for (i, &pgcn) in pgcn_per_title.iter().enumerate() { + let entry = 8 + n * 4 + i * 4; + d[8 + i * 4..12 + i * 4].copy_from_slice(&(entry as u32).to_be_bytes()); + d[entry..entry + 2].copy_from_slice(&pgcn.to_be_bytes()); + d[entry + 2..entry + 4].copy_from_slice(&1u16.to_be_bytes()); + } + d + } + + /// TTN -> PGC goes through VTS_PTT_SRPT, not "TTN N is PGCIT entry N"; the PGCIT + /// sits at a NON-ZERO offset so the pgcit base term is exercised, and the real TTN + /// is stamped on each title (disc/dvd.rs joins on it). + #[test] + fn pgcit_maps_title_numbers_through_ptt_srpt() { + let pgc0 = build_pgc(bcd_secs(11), &[(0x00, bcd_secs(11), 0, 9)], &[], None); + let pgc1 = build_pgc(bcd_secs(22), &[(0x00, bcd_secs(22), 50, 59)], &[], None); + let pgcit = build_pgcit(&[pgc0, pgc1]); + let pgcit_at = 512usize; + let mut data = vec![0u8; pgcit_at]; + data.extend_from_slice(&pgcit); + let ptt_at = data.len(); + // Title 1 -> PGC 2, title 2 -> PGC 1, title 3 -> PGC 2 (past... TTN > num_pgcs). + data.extend_from_slice(&build_ptt_srpt(&[2, 1, 2])); + + let titles = parse_pgcit(&data, pgcit_at, ptt_at, &[(5, 1), (6, 2), (7, 3)]).unwrap(); + let got: Vec<(u8, f64)> = titles + .iter() + .map(|t| (t.vts_title_num, t.duration_secs)) + .collect(); + assert_eq!(got, vec![(1, 22.022), (2, 11.011), (3, 22.022)]); + + // No PTT_SRPT (offset 0), or a PGCN of 0: fall back to the 1:1 assumption. + let titles = parse_pgcit(&data, pgcit_at, 0, &[(5, 1)]).unwrap(); + assert_eq!(titles[0].duration_secs, 11.011); + let mut bad = data.clone(); + let entry = ptt_at + 8 + 3 * 4; // title 1's (pgcn, pgn) + bad[entry..entry + 2].copy_from_slice(&0u16.to_be_bytes()); + let titles = parse_pgcit(&bad, pgcit_at, ptt_at, &[(5, 1)]).unwrap(); + assert_eq!(titles[0].duration_secs, 11.011); + } + + /// A PTT_SRPT PGCN past the table falls back to the 1:1 mapping, not a dropped title. + #[test] + fn pgcit_ptt_pgcn_past_table_falls_back_to_one_to_one() { + let pgc0 = build_pgc(bcd_secs(11), &[(0x00, bcd_secs(11), 0, 9)], &[], None); + let pgc1 = build_pgc(bcd_secs(22), &[(0x00, bcd_secs(22), 50, 59)], &[], None); + let pgcit = build_pgcit(&[pgc0, pgc1]); + let pgcit_at = 512usize; + let mut data = vec![0u8; pgcit_at]; + data.extend_from_slice(&pgcit); + let ptt_at = data.len(); + data.extend_from_slice(&build_ptt_srpt(&[9, 9])); + let titles = parse_pgcit(&data, pgcit_at, ptt_at, &[(5, 1), (6, 2)]).unwrap(); + let got: Vec = titles.iter().map(|t| t.duration_secs).collect(); + assert_eq!(got, vec![11.011, 22.022]); + } + + /// A title number past num_pgcs is dropped, even when an in-buffer 8-byte + /// "entry" happens to sit at that index. + #[test] + fn pgcit_title_past_num_pgcs_is_dropped() { + let pgc0 = build_pgc(bcd_secs(11), &[(0x00, bcd_secs(11), 0, 9)], &[], None); + let pgc1 = build_pgc(bcd_secs(22), &[(0x00, bcd_secs(22), 50, 59)], &[], None); + let mut data = build_pgcit(&[pgc0.clone(), pgc0.clone()]); + // Entry index 2 lands inside PGC 0's bytes; make it point at a valid PGC. + let pgc1_at = data.len(); + data.extend_from_slice(&pgc1); + data[28..32].copy_from_slice(&(pgc1_at as u32).to_be_bytes()); + let titles = parse_pgcit(&data, 0, 0, &[(5, 3)]).unwrap(); + assert!(titles.is_empty(), "TTN 3 of a 2-PGC table must be omitted"); + } + + /// Cells with no rate flag fall back to literal seconds; in a mixed-rate PGC the + /// first rate seen wins for every frame count. + #[test] + fn pgc_chapter_times_unknown_and_mixed_rates() { + let unknown = |secs: u8| [0, 0, secs, 0]; + let pgc = build_pgc( + bcd_secs(59), + &[ + (0x00, unknown(0x10), 0, 9), + (0x00, bcd_secs(20), 10, 19), + (0x00, unknown(0x05), 20, 29), + ], + &[1, 2, 3], + None, + ); + let t = parse_pgc(&pgc, 0, 3).unwrap(); + let want = [0.0, 10.0, 30.02]; + for (g, w) in t.chapter_times.iter().zip(want) { + assert!( + (g - w).abs() < 1e-9, + "unknown-rate chapters {:?}", + t.chapter_times + ); + } + // PAL (25 fps) cell first, then NTSC cells: everything is read at the PAL rate. + let pal10 = [0, 0, 0x10, 0b01_000000]; + let pgc = build_pgc( + bcd_secs(59), + &[ + (0x00, pal10, 0, 9), + (0x00, bcd_secs(20), 10, 19), + (0x00, bcd_secs(10), 20, 29), + ], + &[1, 3], + None, + ); + let t = parse_pgc(&pgc, 0, 2).unwrap(); + // (250 + 600) frames at 1/25 s. + assert!( + (t.chapter_times[1] - 34.0).abs() < 1e-9, + "{:?}", + t.chapter_times + ); + } + + // ── VMG / VTS through a real UDF image ─────────────────────────────── + + use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir}; + + // A VTS_XX_0.IFO: header at sector 0, PGCIT at sector 2, optional PTT_SRPT at sector 3. + fn build_vts_ifo(audio: u16, subs: u16, pgcs: &[Vec], ptt: Option<&[u16]>) -> Vec { + let mut d = vec![0u8; 4 * 2048]; + d[0..12].copy_from_slice(VTS_MAGIC); + d[0xC4..0xC8].copy_from_slice(&7u32.to_be_bytes()); // vtstt_vobs + d[0xCC..0xD0].copy_from_slice(&2u32.to_be_bytes()); // PGCIT sector + d[0x202..0x204].copy_from_slice(&audio.to_be_bytes()); + d[0x254..0x256].copy_from_slice(&subs.to_be_bytes()); + let pgcit = build_pgcit(pgcs); + d[2 * 2048..2 * 2048 + pgcit.len()].copy_from_slice(&pgcit); + if let Some(p) = ptt { + d[0xC8..0xCC].copy_from_slice(&3u32.to_be_bytes()); + let t = build_ptt_srpt(p); + d[3 * 2048..3 * 2048 + t.len()].copy_from_slice(&t); + } + d + } + + fn video_ts_disc(files: Vec<(&str, Vec)>) -> (MemDisc, UdfFs) { + let files = files + .into_iter() + .enumerate() + .map(|(i, (n, c))| file_with(n, 30 + i as u32, 5000 + 100 * i as u32, c, false)) + .collect(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "VIDEO_TS".into(), + icb_lba: 20, + dir_data_lba: 21, + files, + subdirs: vec![], + }], + }; + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + (disc, udf) + } + + fn one_pgc() -> Vec> { + vec![build_pgc( + bcd_secs(22), + &[(0x00, bcd_secs(22), 50, 59)], + &[], + None, + )] + } + + /// The 8 audio / 32 subtitle stream caps, the ptt pointer at 0xC8 and the vob + /// start rebase, all through parse_vts. + #[test] + fn parse_vts_caps_streams_and_reads_ptt_and_vob_start() { + let two = vec![ + build_pgc(bcd_secs(11), &[(0x00, bcd_secs(11), 0, 9)], &[], None), + build_pgc(bcd_secs(22), &[(0x00, bcd_secs(22), 50, 59)], &[], None), + ]; + let ifo = build_vts_ifo(0xFFFF, 0xFFFF, &two, Some(&[2])); + let (mut disc, udf) = video_ts_disc(vec![("VTS_01_0.IFO", ifo)]); + let ts = parse_vts(&mut disc, &udf, 1, &[(5, 1)]).expect("vts parses"); + assert_eq!(ts.audio_streams.len(), 8); + assert_eq!(ts.subtitle_streams.len(), 32); + let lba = udf + .file_start_lba(&mut disc, "/VIDEO_TS/VTS_01_0.IFO") + .unwrap(); + assert_eq!(ts.vob_start_sector, lba + 7); + assert_eq!(ts.titles[0].duration_secs, 22.022, "TTN 1 -> PGC 2 via PTT"); + } + + #[test] + fn parse_vts_rejects_bad_magic_and_short_files() { + let mut bad_magic = build_vts_ifo(0, 0, &one_pgc(), None); + bad_magic[0] = b'X'; + let short = bad_magic[..0x100].to_vec(); + let mut short_ok_magic = short.clone(); + short_ok_magic[0..12].copy_from_slice(VTS_MAGIC); + let (mut disc, udf) = video_ts_disc(vec![ + ("VTS_01_0.IFO", bad_magic), + ("VTS_02_0.IFO", short_ok_magic), + ]); + for n in [1u8, 2] { + let r = parse_vts(&mut disc, &udf, n, &[(5, 1)]); + assert!(matches!(r, Err(Error::IfoParse)), "vts {n}: {r:?}"); + } + } + + fn vmg_at_sector(sector: u32, entries: &[(u16, u8, u8)]) -> Vec { + vmg_with_tt_srpt(sector, entries) + } + + /// parse_vmg_with reads the TT_SRPT pointer at 0xC4 as a SECTOR offset, guards + /// magic/length/bounds, and skips a title set whose IFO is missing. + #[test] + fn parse_vmg_with_reads_sector_pointer_and_skips_failed_title_sets() { + let vts = build_vts_ifo(1, 1, &one_pgc(), None); + let (mut disc, udf) = video_ts_disc(vec![("VTS_01_0.IFO", vts)]); + // Sector 3 (not 1): a byte-offset or wrong-field read would find nothing. + let vmg = vmg_at_sector(3, &[(5, 1, 1), (5, 2, 1)]); + let info = parse_vmg_with(&mut disc, &udf, Some(&vmg)).expect("vmg parses"); + assert_eq!(info.title_sets.len(), 1, "VTS 2 has no IFO: skipped"); + assert_eq!(info.title_sets[0].vts_number, 1); + assert_eq!(info.title_sets[0].titles[0].vts_title_num, 1); + + let mut bad_magic = vmg.clone(); + bad_magic[0] = b'X'; + let short = vmg[..0xC7].to_vec(); + let mut oob = vmg.clone(); + oob[0xC4..0xC8].copy_from_slice(&1000u32.to_be_bytes()); + let mut huge = vmg.clone(); + huge[0xC4..0xC8].copy_from_slice(&u32::MAX.to_be_bytes()); + for (name, v) in [ + ("magic", bad_magic), + ("short", short), + ("oob", oob), + ("huge", huge), + ] { + let r = parse_vmg_with(&mut disc, &udf, Some(&v)); + assert!(matches!(r, Err(Error::IfoParse)), "{name}: {r:?}"); + } + } + + // Reader that reports a halt once armed. + struct HaltingDisc { + inner: MemDisc, + halted: bool, + } + impl SectorSource for HaltingDisc { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + if self.halted { + return Err(Error::Halted); + } + self.inner.read_sectors(lba, count, buf, recovery) + } + } + + /// A halted drive is not a placeholder title set: Halted propagates instead of + /// silently truncating the scan. + #[test] + fn parse_vmg_with_propagates_halted() { + let vts = build_vts_ifo(0, 0, &one_pgc(), None); + let (disc, udf) = video_ts_disc(vec![("VTS_01_0.IFO", vts)]); + let mut reader = HaltingDisc { + inner: disc, + halted: true, + }; + let vmg = vmg_at_sector(1, &[(5, 1, 1)]); + let r = parse_vmg_with(&mut reader, &udf, Some(&vmg)); + assert!(matches!(r, Err(Error::Halted)), "got {r:?}"); + } } diff --git a/src/io/bounded.rs b/src/io/bounded.rs index fefbc828..f74b6b70 100644 --- a/src/io/bounded.rs +++ b/src/io/bounded.rs @@ -144,7 +144,7 @@ mod tests { let halt = Halt::new(); let halt2 = halt.clone(); // Flip halt after ~300ms, long enough that the receive loop has rolled - // at least one 250ms slice and is back in `recv_timeout`. + // several WAIT_SLICE polls and is back in `recv_timeout`. thread::spawn(move || { thread::sleep(Duration::from_millis(300)); halt2.cancel(); @@ -167,28 +167,6 @@ mod tests { assert!(matches!(r, Err(BoundedError::WorkerLost))); } - #[test] - fn halt_already_set_before_call_still_returns_halted() { - // Halt observed on the very first poll slice. The op blocks - // forever; we must not wait the full timeout to notice the - // halt is already set. - let halt = Halt::new(); - halt.cancel(); - let started = Instant::now(); - let r = bounded_syscall(Some(&halt), Duration::from_secs(10), || { - thread::sleep(Duration::from_secs(10)); - 0u32 - }); - assert!(matches!(r, Err(BoundedError::Halted))); - // Should bail out within ~1 s; allow 2 s of slack for slow - // CI hosts. - assert!( - started.elapsed() < Duration::from_secs(2), - "halt-already-set took {:?}", - started.elapsed() - ); - } - #[test] fn ok_path_takes_no_halt_token() { // Sanity: the `None` halt path is the documented zero-config @@ -222,7 +200,7 @@ mod tests { // The op closure must not have been scheduled at all. assert!( !ran.load(Ordering::SeqCst), - "op ran despite pre-cancelled halt — short-circuit at line 108 broken" + "op ran despite pre-cancelled halt — start() short-circuit broken" ); } @@ -281,6 +259,17 @@ mod tests { } } + /// A panicking worker is `WorkerLost` in the stall variant, not a stall `Timeout`. + #[test] + fn stall_variant_reports_worker_lost_on_panic() { + let p = Progress::new(); + let mut timer = StallTimer::new(Duration::from_secs(5), &p); + let r = bounded_syscall_stall(None, &p, &mut timer, &mut || {}, || -> u8 { + panic!("intentional test panic"); + }); + assert!(matches!(r, Err(BoundedError::WorkerLost))); + } + /// HR1 for one blocked call: a worker blocked past the window while `tick` bumps the /// progress is waited for; the same call with no progress times out at the window. #[test] diff --git a/src/io/byte_prefetcher.rs b/src/io/byte_prefetcher.rs index 9cebda22..8cc02f3e 100644 --- a/src/io/byte_prefetcher.rs +++ b/src/io/byte_prefetcher.rs @@ -4,7 +4,7 @@ //! producer re-fills in place, for zero allocations and zero cross-thread frees in the hot //! loop. Works for any stream whose source is an `io::Read`, not just a `SectorSource`. -use crate::halt::{Halt, Recv, SendOutcome}; +use crate::halt::{Halt, Joined, Recv, SendOutcome, join_within}; use crossbeam_channel::{Receiver, Sender, bounded}; use std::io::Read; use std::thread::JoinHandle; @@ -31,23 +31,36 @@ pub const DEFAULT_CHUNK_BYTES: usize = 16 * 1024 * 1024; /// producer-thread join handle so dropping the shell joins the /// producer. /// -/// Drop blocks until the producer exits. For a prompt exit, drop the -/// forward receiver and recycle sender first (channel disconnection) -/// or cancel the [`Halt`] passed to [`BytePrefetcher::new`], observed -/// within one [`WAIT_SLICE`](crate::halt::WAIT_SLICE) even while parked on a channel op. +/// Drop waits a bounded grace for the producer, then detaches it (a `read()` that never +/// returns cannot be interrupted). For a prompt exit, drop the forward receiver and recycle +/// sender first, or cancel the [`Halt`] passed to [`BytePrefetcher::new`]. pub struct PrefetchShell { producer: Option>, } +// How long Drop waits for the producer before detaching it. +const DROP_GRACE: Duration = if cfg!(test) { + Duration::from_millis(500) +} else { + Duration::from_secs(5) +}; + +// Join the producer within `DROP_GRACE`; a producer still blocked in `read()` is detached. +fn join_or_detach(h: JoinHandle<()>) { + if !matches!(join_within(h, DROP_GRACE, None), Joined::Done(_)) { + tracing::warn!(target: "freemkv::io", "byte prefetch producer blocked in read; detached"); + } +} + impl Drop for PrefetchShell { fn drop(&mut self) { if let Some(h) = self.producer.take() { - let _ = h.join(); + join_or_detach(h); } } } -/// Spawned byte prefetcher. Drop joins the producer thread. +/// Spawned byte prefetcher. Drop joins the producer thread (bounded; see [`PrefetchShell`]). pub struct BytePrefetcher { // Non-`Option`: `into_channels`/`Drop` swap in a disconnected stand-in (dead-channel // `mem::replace`, as `sector::PrefetchedSectorSource` does), so a missing `rx` can @@ -67,10 +80,10 @@ impl BytePrefetcher { chunk_bytes: usize, halt: Option, ) -> std::io::Result { - // A zero-length chunk makes every recycled buffer empty; `reader.read(&mut [])` - // returns Ok(0), which the loop below treats as EOF, producing a silent - // zero-byte stream. Callers must pass the downstream demuxer's batch size (> 0). - debug_assert!(chunk_bytes > 0, "BytePrefetcher chunk_bytes must be > 0"); + // A zero-length chunk would read Ok(0) at once: a silent empty stream. + if chunk_bytes == 0 { + return Err(std::io::ErrorKind::InvalidInput.into()); + } let (tx, rx) = bounded::(FORWARD_DEPTH); let (recycle_tx, recycle_rx) = bounded::>(RECYCLE_DEPTH); @@ -102,14 +115,17 @@ impl BytePrefetcher { else { return; }; - // Regrow to chunk_bytes (a short read may have truncated len): sound - // resize, was `unsafe set_len` guarded only by capacity (GHSA-j8ww-f5fg-9pmh - // in `sector::prefetched`). No realloc; a no-op if len is already there. + // Regrow to chunk_bytes (a short read may have truncated len): safe + // resize, was `unsafe set_len` (GHSA-j8ww-f5fg-9pmh in `sector::prefetched`). buf.resize(chunk_bytes, 0); - // Read up to one full chunk. Short reads are - // valid and common — pipe `truncate` so the - // consumer sees only the bytes that arrived. - let n = match reader.read(&mut buf[..]) { + // Short reads are valid: truncate so the consumer sees only what arrived. + let n = loop { + match reader.read(&mut buf[..]) { + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + r => break r, + } + }; + let n = match n { Ok(0) => return, // EOF — drop tx, consumer sees RecvError Ok(n) => n, Err(e) => { @@ -176,7 +192,7 @@ impl Drop for BytePrefetcher { drop(dead_recv); drop(std::mem::replace(&mut self.recycle_tx, dead_send)); if let Some(h) = self.producer.take() { - let _ = h.join(); + join_or_detach(h); } } } @@ -229,6 +245,27 @@ mod tests { ); } + // A reader blocked forever in read() cannot be interrupted: both Drops must return + // after the grace (detaching the producer) instead of wedging the dropping thread. + #[test] + fn drop_detaches_producer_blocked_in_read() { + struct Blocked(crossbeam_channel::Receiver<()>); + impl Read for Blocked { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + let _ = self.0.recv(); + Ok(0) + } + } + let (release, blocked) = bounded::<()>(0); + let pf = BytePrefetcher::new(Blocked(blocked.clone()), 8, None).expect("spawn"); + within(5, move || drop(pf)); + let (_rx, _recycle, shell) = BytePrefetcher::new(Blocked(blocked), 8, None) + .expect("spawn") + .into_channels(); + within(5, move || drop(shell)); + drop(release); + } + // CRITICAL regression: dropping the forward receiver + recycle sender after into_channels // must let the producer see disconnection and exit, so dropping PrefetchShell (join) // returns promptly. @@ -487,6 +524,39 @@ mod tests { }); } + #[test] + fn zero_chunk_is_invalid_input() { + let e = BytePrefetcher::new(Cursor::new(vec![1u8; 4]), 0, None) + .err() + .expect("zero chunk must be rejected"); + assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput); + } + + // EINTR from the reader is retried, not surfaced as a terminal error. + #[test] + fn interrupted_read_is_retried() { + within(10, || { + struct Flaky(u8); + impl Read for Flaky { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0 += 1; + match self.0 { + 1 => Err(std::io::ErrorKind::Interrupted.into()), + 2 => { + buf[..3].fill(9); + Ok(3) + } + _ => Ok(0), + } + } + } + let pf = BytePrefetcher::new(Flaky(0), 8, None).expect("spawn"); + let (got, err) = drain_to_vec(pf); + assert!(err.is_none(), "EINTR surfaced: {err:?}"); + assert_eq!(got, vec![9; 3]); + }); + } + // Dropping BytePrefetcher directly (without into_channels) must join the producer cleanly // on a finite source: EOF drops tx, Drop's join returns. #[test] @@ -511,6 +581,63 @@ mod tests { }); } + // A cancel must release a producer parked on the recycle channel (consumer holds every + // buffer). Producer exit shows as the forward channel disconnecting. + #[test] + fn halt_releases_producer_parked_on_recycle() { + let halt = Halt::new(); + let pf = BytePrefetcher::new(EndlessReader, 64, Some(halt.clone())).expect("spawn"); + let (rx, recycle_tx, shell) = pf.into_channels(); + // Take all RECYCLE_DEPTH buffers and never return them. + let held: Vec<_> = (0..RECYCLE_DEPTH) + .map(|_| rx.recv().expect("batch").expect("read ok")) + .collect(); + halt.cancel(); + let r = rx.recv_timeout(Duration::from_secs(5)); + assert!( + matches!(r, Err(crossbeam_channel::RecvTimeoutError::Disconnected)), + "producer still parked on recycle after cancel: {r:?}" + ); + drop((held, recycle_tx, shell)); + } + + // A buffer truncated by a short read must be regrown before its next read, or the chunk + // size shrinks for good. Records the buffer length each read() is offered. + #[test] + fn truncated_buffer_is_regrown_when_reused() { + use std::sync::{Arc, Mutex}; + struct ShortReads { + left: u8, + lens: Arc>>, + } + impl Read for ShortReads { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.lens.lock().unwrap().push(buf.len()); + if self.left == 0 { + return Ok(0); + } + self.left -= 1; + buf[..3].fill(1); + Ok(3) + } + } + within(10, || { + let lens = Arc::new(Mutex::new(Vec::new())); + // 6 short reads over 3 buffers: every buffer is truncated, then reused. + let r = ShortReads { + left: 6, + lens: lens.clone(), + }; + let pf = BytePrefetcher::new(r, 8, None).expect("spawn"); + let (got, err) = drain_to_vec(pf); + assert!(err.is_none()); + assert_eq!(got.len(), 18); + let lens = lens.lock().unwrap(); + assert_eq!(lens.len(), 7); + assert!(lens.iter().all(|&l| l == 8), "buffer not regrown: {lens:?}"); + }); + } + /// LP9 (×2, §2.1 "Unbounded Drop joins stay plain joins"): after a cancel, dropping /// the prefetcher, or its shell with both channel ends still held, returns within /// 1 s. Guard. diff --git a/src/io/file_sector_source/linux.rs b/src/io/file_sector_source/linux.rs index c63aa1b2..4cfe3279 100644 --- a/src/io/file_sector_source/linux.rs +++ b/src/io/file_sector_source/linux.rs @@ -14,6 +14,7 @@ use std::os::unix::io::AsRawFd; pub(crate) fn hint_sequential(file: &File, _len_bytes: u64) { // Best-effort: return value ignored. A fadvise failure has no // user-observable consequence. + // SAFETY: a valid borrowed fd; an advisory call. unsafe { libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_SEQUENTIAL); } @@ -23,6 +24,7 @@ pub(crate) fn hint_sequential(file: &File, _len_bytes: u64) { /// the page cache. Called periodically by `read_sectors` to bound the /// read-side page cache pressure. pub(crate) fn drop_window(file: &File, start: u64, len: u64) { + // SAFETY: a valid borrowed fd; an advisory call. unsafe { libc::posix_fadvise( file.as_raw_fd(), @@ -36,6 +38,7 @@ pub(crate) fn drop_window(file: &File, start: u64, len: u64) { // Async-prefetch `len` bytes at `offset`: queues readahead(2) without waiting, so the next // batch's I/O overlaps current-batch processing. pub(crate) fn prefetch(file: &File, offset: u64, len: u64) { + // SAFETY: a valid borrowed fd; an advisory call. unsafe { libc::readahead(file.as_raw_fd(), offset as i64, len as usize); } diff --git a/src/io/file_sector_source/macos.rs b/src/io/file_sector_source/macos.rs index e2432428..54df7c74 100644 --- a/src/io/file_sector_source/macos.rs +++ b/src/io/file_sector_source/macos.rs @@ -15,6 +15,7 @@ pub(crate) fn hint_sequential(file: &File, len_bytes: u64) { ra_count: bytes as libc::c_int, }; // Best-effort. + // SAFETY: a valid borrowed fd and a live stack `radvisory`. unsafe { libc::fcntl(file.as_raw_fd(), libc::F_RDADVISE, &mut ra); } diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index 7e695f01..61772624 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -447,29 +447,26 @@ mod tests { ); } + // read_sectors must feed take_drop_window with the real read offsets (the helper + // is tested alone above); a small chunk lets the window state show the wiring. #[test] - fn drop_chunk_size_env_override() { - // Explicit 8 MiB via env var. SAFETY: set_var is `unsafe` since Rust 2024 - // (can race with other threads/TLS), but this test runs single-threaded - // and in-process before any FileSectorSource construction. - unsafe { - std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "8"); - } - assert_eq!(read_drop_chunk_bytes(), 8 * 1024 * 1024); - - unsafe { - std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB"); - } - assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT); - - // Garbage env value falls back to default. - unsafe { - std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "not-a-number"); - } - assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT); - unsafe { - std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB"); - } + fn read_sectors_advances_the_drop_window() { + let dir = tempdir().unwrap(); + let path = dir.path().join("dw.iso"); + make_iso(&path, 32); + let mut src = FileSectorSource::open(&path).unwrap(); + src.drop_chunk_bytes = 64 * SECTOR_BYTES_U64; + let mut buf = vec![0u8; 2 * SECTOR_BYTES]; + src.read_sectors(10, 2, &mut buf, false).unwrap(); + assert_eq!( + src.drop_window, + (10 * SECTOR_BYTES_U64, 12 * SECTOR_BYTES_U64) + ); + src.read_sectors(12, 2, &mut buf, false).unwrap(); + assert_eq!( + src.drop_window, + (10 * SECTOR_BYTES_U64, 14 * SECTOR_BYTES_U64) + ); } // Additional coverage. count==0 must short-circuit to Ok(0) before any diff --git a/src/io/flush.rs b/src/io/flush.rs index b9057eec..0c3bb8f7 100644 --- a/src/io/flush.rs +++ b/src/io/flush.rs @@ -306,6 +306,15 @@ pub(crate) fn wait_failure(e: BoundedError, what: &str) -> io::Error { } } +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn rc(r: libc::c_int) -> io::Result<()> { + if r == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + // The production primitives for `file`. pub(crate) fn os_ops(file: &File) -> Arc { Arc::new(platform::OsFlushOps::for_file(file)) @@ -340,14 +349,6 @@ mod platform { } } - fn rc(r: libc::c_int) -> io::Result<()> { - if r == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } - } - impl FlushOps for OsFlushOps { // SS-12 XSH fdatasync(): "shall force all currently queued I/O operations associated // with the file … to the synchronized I/O completion state". @@ -408,14 +409,6 @@ mod platform { } } - fn rc(r: libc::c_int) -> io::Result<()> { - if r == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } - } - impl FlushOps for OsFlushOps { // A plain fsync per chunk: cheap next to F_FULLFSYNC, and only the pages dirtied // since the last one (§2.10 macOS row). diff --git a/src/io/flush/tests.rs b/src/io/flush/tests.rs index 1ed1e2a9..4ef8af1a 100644 --- a/src/io/flush/tests.rs +++ b/src/io/flush/tests.rs @@ -49,11 +49,16 @@ struct FakeFlushOps { chunk_gate: Gate, finish_sleep: Duration, finish_gate: Gate, + started: AtomicUsize, chunks: AtomicUsize, + // Each chunk call's entry and exit instants. + spans: Mutex>, finishes: AtomicUsize, sample_from: Option, + samples: AtomicUsize, pieces: Option, - ranges: Mutex>, + // Each range call's (offset, length, entry instant). + ranges: Mutex>, } impl Default for FakeFlushOps { @@ -66,9 +71,12 @@ impl Default for FakeFlushOps { chunk_gate: Gate::open(), finish_sleep: Duration::ZERO, finish_gate: Gate::open(), + started: AtomicUsize::new(0), chunks: AtomicUsize::new(0), + spans: Mutex::new(Vec::new()), finishes: AtomicUsize::new(0), sample_from: None, + samples: AtomicUsize::new(0), pieces: None, ranges: Mutex::new(Vec::new()), } @@ -77,6 +85,8 @@ impl Default for FakeFlushOps { impl FlushOps for FakeFlushOps { fn chunk(&self, file: &File) -> io::Result<()> { + let entered = Instant::now(); + self.started.fetch_add(1, Ordering::SeqCst); self.chunk_gate.pass(); std::thread::sleep(self.chunk_sleep); let i = self.chunks.load(Ordering::SeqCst); @@ -89,6 +99,7 @@ impl FlushOps for FakeFlushOps { let dirty = len.saturating_sub(self.flushed_len.swap(len, Ordering::SeqCst)); std::thread::sleep(Duration::from_secs_f64(dirty as f64 / rate as f64)); } + self.spans.lock().unwrap().push((entered, Instant::now())); self.chunks.fetch_add(1, Ordering::SeqCst); Ok(()) } @@ -96,7 +107,7 @@ impl FlushOps for FakeFlushOps { let delay = self.pieces.as_ref()?; let i = { let mut r = self.ranges.lock().unwrap(); - r.push((off, len)); + r.push((off, len, Instant::now())); r.len() - 1 }; std::thread::sleep(delay(i)); @@ -109,6 +120,7 @@ impl FlushOps for FakeFlushOps { Ok(()) } fn sample(&self) -> Option { + self.samples.fetch_add(1, Ordering::SeqCst); self.sample_from.map(|t| t.elapsed().as_millis() as u64) } } @@ -148,26 +160,59 @@ fn cancel_after(halt: &Halt, d: Duration) { }); } -/// LP13a (T12, stall pair a; Q-B "if writing keep going do nothing"): each chunk flush -/// takes 0.5 × window; the flusher keeps completing chunks, so a flush far longer than -/// the window succeeds. +/// LP13a (T12, stall pair a; Q-B "if writing keep going do nothing"): a final flush +/// waits on two chunk flushes of 0.55 × window each; the first completion re-arms the +/// stall window, so a wait longer than the window succeeds. #[test] fn flush_slow_but_progressing_never_fails() { let ops = Arc::new(FakeFlushOps { - chunk_sleep: W / 2, + chunk_sleep: W * 11 / 20, + chunk_gate: Gate::closed(), ..FakeFlushOps::default() }); - let (_d, mut w, _) = fake_file(&ops, timing(W)); - let t = Instant::now(); - for _ in 0..8 { - w.write_all(&[7u8; K as usize]).unwrap(); + let t = FlushTiming { + sample_every: Duration::from_millis(5), + ..timing(W) + }; + let (_d, mut w, _) = fake_file(&ops, t); + // Chunk 0 is in flight (held at the gate) before the second C is requested, so the + // drain needs a second chunk; the gate opens once the drain is waiting (it samples). + w.write_all(&[7u8; K as usize]).unwrap(); + while ops.started.load(Ordering::SeqCst) == 0 { + std::thread::sleep(Duration::from_millis(1)); } - w.sync_all().expect("a progressing flush never fails"); - assert!( - ops.chunks.load(Ordering::SeqCst) >= 4, - "the flusher flushed chunks" - ); - assert!(t.elapsed() > W * 2, "the flush outlived the window"); + w.write_all(&[7u8; K as usize]).unwrap(); + let o = ops.clone(); + std::thread::spawn(move || { + while o.samples.load(Ordering::SeqCst) == 0 { + std::thread::sleep(Duration::from_millis(1)); + } + o.chunk_gate.release(); + }); + let t = Instant::now(); + let r = w.sync_all(); + let end = Instant::now(); + while ops.spans.lock().unwrap().len() < ops.started.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(1)); + } + // Chunk i's progress bump lies between its exit and chunk i + 1's entry, so this bounds + // each no-progress gap the drain saw up to `end`. Only a whole window (a descheduled + // worker under load) may latch SyncTimeout; anything shorter must succeed. + let spans = ops.spans.lock().unwrap().clone(); + let lows = std::iter::once(t).chain(spans.iter().map(|s| s.1)); + let highs = spans.iter().skip(1).map(|s| s.0.min(end)).chain([end]); + let gap = lows + .zip(highs) + .map(|(a, b)| b.saturating_duration_since(a)) + .max() + .unwrap(); + if let Err(e) = &r { + assert!(is_sync_timeout(e) && gap >= W, "{e}; gaps under {gap:?}"); + eprintln!("inconclusive: the worker made no progress for {gap:?} under load"); + return; + } + assert_eq!(spans.len(), 2, "two chunk flushes"); + assert!(end - t > W, "the wait outlived the window: {:?}", end - t); } /// LP13b / G19 (T12, stall pair b): a chunk flush that never returns, with no sampled @@ -295,22 +340,23 @@ fn writer_backpressure_at_two_chunks() { } w }); - std::thread::sleep(Duration::from_millis(150)); + // Only a writer blocked in the backpressure wait samples: that, not a sleep, says the + // 4th write is waiting. Without backpressure it completes instead. + while ops.samples.load(Ordering::SeqCst) == 0 && done.load(Ordering::SeqCst) < 4 { + std::thread::sleep(Duration::from_millis(2)); + } assert_eq!( done.load(Ordering::SeqCst), 3, "the 4th write waits at 2 × C unflushed" ); ops.chunk_gate.release(); - let t = Instant::now(); - while done.load(Ordering::SeqCst) < 4 { - assert!( - t.elapsed() < SLACK, - "a completed flush must release the writer" - ); - std::thread::sleep(Duration::from_millis(2)); - } - writer.join().unwrap().sync_all().unwrap(); + // Unreleased, the writer latches SyncTimeout at its 30 s window and the join fails. + let mut w = writer + .join() + .expect("a completed flush releases the writer"); + assert_eq!(done.load(Ordering::SeqCst), 4); + w.sync_all().unwrap(); } /// LP13f (§2.10 item 1, as revised): C starts at the floor, doubles after fast chunk @@ -554,17 +600,49 @@ fn durable_sync_file_reports_piece_completions() { let mut seen = Vec::new(); let dyn_ops: Arc = ops.clone(); let t = durable_timing(Duration::from_secs(30)); - durable_sync_file_with(&f, None, |d, n| seen.push((d, n)), dyn_ops, t).unwrap(); - let lens: Vec = ops.ranges.lock().unwrap().iter().map(|r| r.1).collect(); - assert_eq!(lens, [4 * K, 8 * K, 16 * K, 8 * K, 4 * K, 8 * K, 16 * K]); + let start = Instant::now(); + let report = |d, n| seen.push((d, n, Instant::now())); + durable_sync_file_with(&f, None, report, dyn_ops, t).unwrap(); + let end = Instant::now(); + let ranges = ops.ranges.lock().unwrap().clone(); + // A piece's measured time lies between its fake call's entry-to-report span and the span + // from the previous report to the next call's entry; the window may take any step + // (halve > target, keep, double < target / 4) that time allows. The 40 ms pieces halve. + let (target, min, max) = (t.piece_target, t.window_min, t.window_max); + let (mut windows, mut done) = (vec![min], 0); + for (i, &(off, len, entered)) in ranges.iter().enumerate() { + assert_eq!(off, done, "piece {i} is contiguous"); + windows.retain(|w| (*w).min(64 * K - done) == len); + assert!(!windows.is_empty(), "piece {i} of {len}: {ranges:?}"); + done += len; + let before = i.checked_sub(1).map_or(start, |p| seen[p].2); + let after = ranges.get(i + 1).map_or(end, |r| r.2); + let (lo, hi) = (seen[i].2 - entered, after - before); + let mut next = Vec::new(); + for w in windows { + if hi > target { + next.push((w / 2).max(min)); + } + if lo <= target && hi >= target / 4 { + next.push(w); + } + if lo < target / 4 { + next.push((w * 2).min(max)); + } + } + next.sort_unstable(); + next.dedup(); + windows = next; + } + assert_eq!(done, 64 * K, "{ranges:?}"); assert_eq!( ops.finishes.load(Ordering::SeqCst), 1, "then the final flush" ); assert!(seen.windows(2).all(|p| p[0].0 < p[1].0), "{seen:?}"); - assert_eq!(seen.last(), Some(&(64 * K, 64 * K))); - assert!(seen.len() >= lens.len(), "one report per completed piece"); + assert_eq!(seen.last().map(|s| (s.0, s.1)), Some((64 * K, 64 * K))); + assert!(seen.len() >= ranges.len(), "one report per completed piece"); } /// LP19, NFS shape: one whole-file flush blocked 3 × window while the sampled diff --git a/src/io/image_writer.rs b/src/io/image_writer.rs index ca4a16c9..952699ae 100644 --- a/src/io/image_writer.rs +++ b/src/io/image_writer.rs @@ -25,7 +25,7 @@ const BATCH_SECTORS: u32 = 2048; /// Reads 2048-sector batches from LBA 0 in order; decrypts nothing. Batches are not whole /// 3-sector AACS units: wrap an AACS decrypting source to read on the unit grid, or it fails /// the first misaligned batch ([`Error::DecryptFailed`]). `on_progress` gets cumulative bytes -/// per batch (must not block); `halt` is checked per batch, keeping the partial file. +/// per batch (must not block); `halt` keeps the partial file, any other failure removes it. /// /// # Errors /// @@ -62,6 +62,31 @@ fn write_image_with( } let file = File::create(dest).map_err(|source| Error::IoError { source })?; + let r = copy_out( + reader, + file, + dest, + total_sectors, + halt, + &mut on_progress, + sync_dir, + ); + // A failed copy leaves no truncated image at the final name; a halt keeps its partial. + if matches!(&r, Err(e) if !matches!(e, Error::Halted)) { + let _ = std::fs::remove_file(dest); + } + r +} + +fn copy_out( + reader: &mut dyn SectorSource, + file: File, + dest: &Path, + total_sectors: u32, + halt: &Halt, + on_progress: &mut impl FnMut(u64), + sync_dir: fn(&Path) -> std::io::Result<()>, +) -> Result { let mut out = BufWriter::with_capacity(BATCH_SECTORS as usize * SECTOR_BYTES, file); let mut buf = vec![0u8; BATCH_SECTORS as usize * SECTOR_BYTES]; @@ -214,6 +239,20 @@ mod tests { p } + // A failed copy must not leave a truncated image at the final name. + #[test] + fn failed_copy_removes_the_incomplete_image() { + let td = tempfile::tempdir().unwrap(); + let dest = td.path().join("bad.iso"); + let mut src = PatternSource { + sectors: 4096, + short_after: Some(2048), + }; + let r = write_image(&mut src, &dest, 4096, &Halt::new(), |_| {}); + assert!(matches!(r, Err(Error::ShortImageRead { .. })), "{r:?}"); + assert!(!dest.exists(), "truncated image left at the final name"); + } + // A failing parent-directory fsync must fail write_image, not report Ok. #[test] fn failed_parent_directory_fsync_is_an_error() { diff --git a/src/io/pipeline.rs b/src/io/pipeline.rs index fee9ab3a..1d75510f 100644 --- a/src/io/pipeline.rs +++ b/src/io/pipeline.rs @@ -101,20 +101,10 @@ fn finish_with_grace( leak_err: Error, on_poll: &mut dyn FnMut(), ) -> Result { - // `None` = a grace past `Instant`'s range: wait unbounded. - let end = Instant::now().checked_add(grace); - let mut handle = handle; - loop { - let left = end.map_or(WAIT_SLICE, |e| e.saturating_duration_since(Instant::now())); - handle = match join_within(handle, left.min(WAIT_SLICE), None) { - Joined::Done(r) => return join_result(r), - Joined::Halted(h) | Joined::Pending(h) => h, - }; - on_poll(); - if end.is_some_and(|e| Instant::now() >= e) { - break; - } - } + let mut handle = match wait_grace(handle, grace, on_poll) { + Ok(r) => return r, + Err(h) => h, + }; // CLAIM abandonment before dropping the handle so the leaked consumer skips further // `apply`/`close()`. Compare-exchange, not a store: a consumer already CLOSING wins. if state @@ -170,18 +160,33 @@ fn grace_then_leak( leak_err: Error, on_poll: &mut dyn FnMut(), ) -> Result { + match wait_grace(handle, grace, on_poll) { + Ok(r) => r, + Err(handle) => { + drop(handle); + Err(leak_err) + } + } +} + +// Join within `grace` (polling `on_poll`); `Err(handle)` if it is still running. +fn wait_grace( + handle: thread::JoinHandle>, + grace: Duration, + on_poll: &mut dyn FnMut(), +) -> Result, thread::JoinHandle>> { + // `None` = a grace past `Instant`'s range: wait unbounded. let end = Instant::now().checked_add(grace); let mut handle = handle; loop { let left = end.map_or(WAIT_SLICE, |e| e.saturating_duration_since(Instant::now())); handle = match join_within(handle, left.min(WAIT_SLICE), None) { - Joined::Done(r) => return join_result(r), + Joined::Done(r) => return Ok(join_result(r)), Joined::Halted(h) | Joined::Pending(h) => h, }; on_poll(); if end.is_some_and(|e| Instant::now() >= e) { - drop(handle); - return Err(leak_err); + return Err(handle); } } } @@ -191,9 +196,8 @@ fn grace_then_leak( /// use WRITE_PIPELINE_DEPTH instead. pub const DEFAULT_PIPELINE_DEPTH: usize = 4; -/// Write pipeline depth. Smaller buffer reduces backpressure risk when -/// sync_file_range blocks; prevents producer from accumulating too much -/// work while consumer waits for NFS to drain. +/// Write pipeline depth: deeper than the default (16 vs 4), so the producer keeps +/// running while a slow flush (`sync_file_range`, NFS drain) blocks the consumer. pub const WRITE_PIPELINE_DEPTH: usize = 16; /// Channel depth for write-through pipelines. Each `send` fully @@ -207,9 +211,8 @@ pub const WRITE_THROUGH_DEPTH: usize = 1; /// ([`Flow::Continue`]), or stop the pipeline early and run `close()` /// ([`Flow::Stop`]). /// -/// `Stop` currently has no in-tree caller (sweep always processes its -/// full work-list; the mux highway drains to EOF), but it's part of the -/// fixed `Sink` contract. +/// `Stop` currently has no in-tree caller (the mux highway drains to EOF; the sweep +/// moved to freemkv-engine), but it's part of the fixed `Sink` contract. pub enum Flow { Continue, Stop, @@ -258,7 +261,7 @@ pub struct Pipeline { /// Set by [`finish_with_grace`] when the grace period expires and the consumer thread is /// about to be leaked: it stops applying further items and does NOT call `close()`, so a /// leaked consumer can't finalise an output already reported as failed. One of - /// [`state::RUNNING`] / [`state::ABANDONED`] / [`state::CLOSING`]; both transitions are + /// [`state::RUNNING`] / [`state::ABANDONED`] / [`state::CLOSING`] / [`state::CLOSING_STOPPED`]; both transitions are /// compare-exchanges so abandoning and finalising are mutually exclusive rather than /// racing. state: Arc, @@ -513,10 +516,9 @@ impl Pipeline { /// the item back if the consumer thread is gone (panicked or /// already returned). /// - /// After [`Flow::Stop`], `send` silently buffers until the channel - /// fills, then returns `Err(item)` once the consumer drops its - /// receiver — producers that need to stop pushing on `Stop` should - /// track an independent signal (e.g. `Halt`) instead. + /// After [`Flow::Stop`] the consumer keeps draining (discarding) until the producer + /// drops `tx`, so `send` keeps returning `Ok` — producers that need to stop pushing on + /// `Stop` should track an independent signal (e.g. `Halt`) instead. pub fn send(&self, item: I) -> Result<(), I> { // Only timestamp when debug tracing is on — `send` runs per // item on the mux highway hot path. @@ -933,7 +935,9 @@ mod tests { type Output = (); fn apply(&mut self, _item: u64) -> Result { - panic!("synthetic test panic"); + // resume_unwind skips the process-global panic hook, so no test has to + // swap the hook (a race between parallel tests) to keep output quiet. + std::panic::resume_unwind(Box::new("synthetic test panic")); } fn close(self) -> Result<(), Error> { @@ -943,11 +947,6 @@ mod tests { #[test] fn consumer_panic_becomes_io_error() { - // Silence the panic message that would otherwise pollute the - // test output — we expect this panic. - let prev = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); - let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn should succeed"); // First send may succeed (item buffered before panic) or fail @@ -960,8 +959,6 @@ mod tests { } let res = pipe.finish(); - std::panic::set_hook(prev); - // A consumer panic surfaces as the numeric variant, not an // English-carrying io::Error. The original panic payload is // logged at the join site, not embedded in the error value. @@ -1254,8 +1251,6 @@ mod tests { // try_send must report Disconnected once the consumer has exited (via panic here). #[test] fn try_send_reports_disconnected_after_consumer_gone() { - let prev = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn"); // Drive the consumer to panic and fully exit. Spin until a // try_send observes the closed channel. @@ -1272,7 +1267,6 @@ mod tests { } std::thread::sleep(Duration::from_millis(10)); } - std::panic::set_hook(prev); let _ = pipe.finish(); assert!( saw_disconnect, @@ -1283,8 +1277,6 @@ mod tests { // Plain send must hand the item back via Err(item) once the consumer has panicked. #[test] fn send_returns_item_after_consumer_panicked() { - let prev = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn"); let end = Instant::now() + Duration::from_secs(2); let mut returned = None; @@ -1296,7 +1288,6 @@ mod tests { } std::thread::sleep(Duration::from_millis(10)); } - std::panic::set_hook(prev); let _ = pipe.finish(); assert_eq!( returned, @@ -1308,8 +1299,6 @@ mod tests { // send_with_halt must return the exact item via Err(item) on the Disconnected arm. #[test] fn send_with_halt_returns_item_on_disconnect() { - let prev = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn"); // Force the consumer to panic + exit: send until the channel // closes (plain send returns Err). @@ -1322,7 +1311,6 @@ mod tests { } let halt = crate::halt::Halt::new(); // never cancelled let res = pipe.send_with_halt(0xABCD_u64, &halt, Duration::from_secs(5)); - std::panic::set_hook(prev); let _ = pipe.finish(); assert!( matches!(res, Err(0xABCD)), diff --git a/src/io/sink/mod.rs b/src/io/sink/mod.rs index 8622bd01..f6046012 100644 --- a/src/io/sink/mod.rs +++ b/src/io/sink/mod.rs @@ -21,8 +21,8 @@ pub use socket::{SocketSink, UdpSocketSink}; /// /// `finish` drains any internal buffering and signals end-of-stream to the transport /// (close-write on a socket, flush + fsync on a buffered file, etc.). The default impl flushes -/// via [`Write::flush`]; every concrete sink here overrides it with its own finalisation. No -/// blanket `impl SequentialSink for T` +/// via [`Write::flush`]; every concrete sink here overrides it with its own finalisation. There +/// is deliberately no blanket `impl SequentialSink for T`. pub trait SequentialSink: Write + Send { fn finish(&mut self) -> std::io::Result<()> { self.flush() @@ -34,67 +34,10 @@ pub trait SequentialSink: Write + Send { /// random-access sink is always usable as a sequential sink. pub trait RandomAccessSink: SequentialSink + Seek {} -// Picks the right RandomAccessSink impl for `dest` by filesystem type (Linux+NFS -> -// WritebackFile, else LocalFileSink). Not yet wired into mux::resolve. -#[allow(dead_code)] -pub(crate) fn open_for_mkv( - dest: &std::path::Path, - size_hint: Option, -) -> std::io::Result> { - #[cfg(target_os = "linux")] - { - use crate::platform::fs_type::FsType; - if classify_dest(dest) == FsType::Nfs { - let wf = match size_hint { - Some(n) => crate::io::WritebackFile::create_with_size_hint(dest, n)?, - None => crate::io::WritebackFile::create(dest)?, - }; - return Ok(Box::new(wf)); - } - } - // Only Linux differentiates the sink by filesystem type; other OSes always use - // `LocalFileSink`. Reference `classify_dest` as a value (no call/syscall) so it - // isn't flagged dead on non-Linux while avoiding a wasted probe. - #[cfg(not(target_os = "linux"))] - let _ = classify_dest; - - let sink = match size_hint { - Some(n) => LocalFileSink::with_size_hint(dest, n)?, - None => LocalFileSink::create(dest)?, - }; - Ok(Box::new(sink)) -} - -// Filesystem type of the output location. statfs needs an existing path and -// `dest` usually is not created yet, so probe its directory. -fn classify_dest(dest: &std::path::Path) -> crate::platform::fs_type::FsType { - crate::platform::fs_type::detect(probe_dir(dest)) -} - -fn probe_dir(dest: &std::path::Path) -> &std::path::Path { - match dest.parent() { - Some(p) if !p.as_os_str().is_empty() => p, - _ => std::path::Path::new("."), - } -} - #[cfg(test)] mod tests { use super::*; - // `dest` normally does not exist yet, so statfs on it fails (Unknown) and the - // NFS branch was unreachable. The classification must come from its directory. - #[test] - fn classify_dest_probes_the_directory_of_a_new_file() { - use std::path::Path; - assert_eq!( - probe_dir(Path::new("/mnt/nfs/out/Movie.mkv")), - Path::new("/mnt/nfs/out") - ); - // A bare file name lives in the current directory. - assert_eq!(probe_dir(Path::new("bare.mkv")), Path::new(".")); - } - // Type-level assertion: the concrete sinks satisfy the trait // objects. These functions never run; they just have to type-check. fn _assert_is_sequential(_: &mut dyn SequentialSink) {} @@ -115,20 +58,6 @@ mod tests { _assert_is_random_access(&mut wf); } - #[test] - fn open_for_mkv_returns_a_random_access_sink() { - let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("c.bin"); - let mut sink = open_for_mkv(&p, Some(64 * 1024)).unwrap(); - use std::io::{Seek, SeekFrom, Write}; - sink.write_all(b"hello").unwrap(); - sink.seek(SeekFrom::Start(0)).unwrap(); - sink.finish().unwrap(); - drop(sink); - let bytes = std::fs::read(&p).unwrap(); - assert_eq!(&bytes[..5], b"hello"); - } - // Regression test for the silent-no-op finish() bug. #[test] fn finish_through_trait_object_flushes_local_file() { @@ -191,19 +120,4 @@ mod tests { ); assert_eq!(bytes.load(Ordering::SeqCst), 3); } - - // Covers the `None` size_hint arm: must still be a random-access sink. - #[test] - fn open_for_mkv_without_size_hint_is_random_access() { - use std::io::{Seek, SeekFrom}; - let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("nohint.bin"); - let mut sink = open_for_mkv(&p, None).unwrap(); - sink.write_all(b"AAAABBBB").unwrap(); - sink.seek(SeekFrom::Start(4)).unwrap(); - sink.write_all(b"CCCC").unwrap(); - sink.finish().unwrap(); - drop(sink); - assert_eq!(std::fs::read(&p).unwrap(), b"AAAACCCC"); - } } diff --git a/src/io/sink/socket.rs b/src/io/sink/socket.rs index 0bbd56dd..82e45746 100644 --- a/src/io/sink/socket.rs +++ b/src/io/sink/socket.rs @@ -225,7 +225,7 @@ mod tests { } #[test] - fn socket_sink_is_sequential_only() { + fn socket_sink_coerces_to_sequential_sink() { // Compile-time assertion via dyn — if this ever started // satisfying `RandomAccessSink`, the trait split would be broken. fn _assert_seq(_: &mut dyn super::super::SequentialSink) {} @@ -236,9 +236,23 @@ mod tests { }); let mut sink = SocketSink::connect(addr, None).unwrap(); _assert_seq(&mut sink); - // No `is_not` to assert the negative directly, but `SocketSink` doesn't - // impl `Seek`, so it can't unify with `RandomAccessSink`'s super-bound — - // the blanket `impl RandomAccessSink for T {}` excludes it. + // Only the positive coercion is checked: `RandomAccessSink` impls are explicit + // (local_file, writeback_file), so a socket needs a deliberate new impl + `Seek`. + } + + #[test] + fn udp_socket_sink_binds_ipv6_for_an_ipv6_peer() { + let Ok(receiver) = UdpSocket::bind("[::1]:0") else { + return; // host has no IPv6 loopback + }; + receiver + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let mut sink = UdpSocketSink::connect(receiver.local_addr().unwrap(), None).unwrap(); + sink.write_all(&[7, 8, 9]).unwrap(); + let mut buf = [0u8; 8]; + let n = receiver.recv(&mut buf).unwrap(); + assert_eq!(&buf[..n], &[7, 8, 9]); } #[test] diff --git a/src/io/writeback/linux.rs b/src/io/writeback/linux.rs index 7a609dbc..e738a9d8 100644 --- a/src/io/writeback/linux.rs +++ b/src/io/writeback/linux.rs @@ -58,12 +58,8 @@ pub(crate) struct WritebackPipeline { /// we skip it entirely and let the NFS client handle commit on /// close. is_nfs: bool, - /// Set the first time WAIT_AFTER exceeds [`WAIT_AFTER_TIMEOUT`]. - /// Once set, behaviour matches the NFS path for the rest of the - /// pipeline's life. A plain `AtomicBool`: the flag is only ever - /// touched on the owning thread (the spawned WAIT_AFTER worker never - /// reads or writes it). `AtomicBool` over `bool` only because the - /// load/store sites read cleanly; no sharing is needed today. + /// Set when WAIT_AFTER exceeds [`WAIT_AFTER_TIMEOUT`]; behaves like NFS from then on. + /// Owning-thread access only. degraded: AtomicBool, /// False for fds `sync_file_range` rejects (pipes, char devices such as /// `/dev/null`): those skip kickoff, WAIT_AFTER and DONTNEED entirely. @@ -83,6 +79,7 @@ pub(crate) struct WritebackPipeline { type WaitOp = fn(RawFd, u64, u64) -> i32; fn sys_wait_after(fd: RawFd, off: u64, len: u64) -> i32 { + // SAFETY: a plain syscall on an fd; no user memory is touched. let rc = unsafe { libc::sync_file_range(fd, off as i64, len as i64, libc::SYNC_FILE_RANGE_WAIT_AFTER) }; @@ -99,6 +96,8 @@ enum WaitOutcome { Done(u64), Failed(i32), TimedOut, + // No WAIT_AFTER ran (worker lost): nothing durable, nothing to time. + Skipped, } impl WritebackPipeline { @@ -238,6 +237,7 @@ impl WritebackPipeline { wait_ms = ms; self.flush.add_durable(prev_len); let t_fadv = Instant::now(); + // SAFETY: a valid fd; an advisory call. unsafe { libc::posix_fadvise( self.fd, @@ -250,6 +250,7 @@ impl WritebackPipeline { self.record_wait(wait_ms); } WaitOutcome::Failed(errno) => self.latch_error(errno, prev_off, prev_len), + WaitOutcome::Skipped => {} WaitOutcome::TimedOut => { // Timeout branch: switch to NFS-style skip for the rest of the // pipeline's life. Do NOT call DONTNEED — if WAIT_AFTER hasn't @@ -409,6 +410,7 @@ impl WritebackPipeline { } } WaitOutcome::Failed(errno) => self.latch_error(errno, prev_off, prev_len), + WaitOutcome::Skipped => {} WaitOutcome::TimedOut => { self.degraded.store(true, Ordering::Relaxed); tracing::error!( @@ -483,7 +485,7 @@ fn wait_after_with_timeout( | Err(crate::io::bounded::BoundedError::Halted) => WaitOutcome::TimedOut, // Worker spawn failed or panicked before sending: no syscall ran, so // nothing was consumed. Benign, not a degrade trigger. - Err(crate::io::bounded::BoundedError::WorkerLost) => WaitOutcome::Done(0), + Err(crate::io::bounded::BoundedError::WorkerLost) => WaitOutcome::Skipped, } } @@ -501,6 +503,16 @@ mod tests { (f, pipeline) } + // A lost worker ran no WAIT_AFTER: it must not count as a completed (0 ms) wait. + #[test] + fn lost_wait_worker_is_skipped_not_done() { + fn panicking(_: RawFd, _: u64, _: u64) -> i32 { + panic!("intentional test panic"); + } + let out = wait_after_with_timeout(None, -1, 0, 1, panicking, Duration::from_secs(2)); + assert!(matches!(out, WaitOutcome::Skipped)); + } + #[test] fn new_pipeline_starts_active() { let (_f, p) = local_pipeline(32 * 1024 * 1024); @@ -563,6 +575,35 @@ mod tests { ); } + // p95 of a full window is its top sample (index 15 of 16): one slow wait among fast + // ones grows the chunk. Uniform samples cannot tell `sorted[15]` from `sorted[0]`. + #[test] + fn record_wait_takes_the_top_sample_as_p95() { + let (_f, mut p) = local_pipeline(16 * 1024 * 1024); + for _ in 0..ADAPTIVE_WINDOW - 1 { + p.record_wait(1); + } + p.record_wait(ADAPTIVE_GROW_MS + 50); + assert_eq!(p.chunk_bytes, 32 * 1024 * 1024); + } + + // The window evicts its OLDEST sample: a slow wait stays in the window for exactly + // ADAPTIVE_WINDOW pushes, then leaves and the chunk shrinks. + #[test] + fn record_wait_evicts_the_oldest_sample() { + let (_f, mut p) = local_pipeline(16 * 1024 * 1024); + for _ in 0..ADAPTIVE_WINDOW - 1 { + p.record_wait(1); + } + p.record_wait(ADAPTIVE_GROW_MS + 50); // push #16: grows, stays in the window + for _ in 0..ADAPTIVE_WINDOW - 1 { + p.record_wait(1); // pushes #17..#31: the slow sample is still inside + } + assert_eq!(p.chunk_bytes, CHUNK_BYTES_MAX); + p.record_wait(1); // push #32 evicts it: all fast, so shrink + assert_eq!(p.chunk_bytes, CHUNK_BYTES_MAX / 2); + } + #[test] fn record_wait_clamps_to_chunk_bounds() { // Grow past the max. diff --git a/src/io/writeback_file/flusher.rs b/src/io/writeback_file/flusher.rs index 635f7ded..f3e2e6eb 100644 --- a/src/io/writeback_file/flusher.rs +++ b/src/io/writeback_file/flusher.rs @@ -113,7 +113,16 @@ impl Flusher { /// Backpressure before a write: wait while more than `2 × C` is unflushed. pub(super) fn wait_room(&self, written: u64, halt: Option<&Halt>) -> io::Result<()> { self.wait(halt, halt, |st| { - written.saturating_sub(st.flushed) <= 2 * st.chunk + if written.saturating_sub(st.flushed) <= 2 * st.chunk { + return true; + } + // Never block on bytes the worker was not asked to flush: re-checked under the + // lock on every wake, as the chunk may shrink while this writer waits. + if written > st.requested { + st.requested = written; + self.shared.cv.notify_all(); + } + false }) } @@ -143,7 +152,7 @@ impl Flusher { &self, abort: Option<&Halt>, stop: Option<&Halt>, - done: impl Fn(&State) -> bool, + mut done: impl FnMut(&mut State) -> bool, ) -> io::Result<()> { let progress = self.lock().flush.progress().clone(); let mut timer = StallTimer::new(self.timing.stall, &progress); @@ -153,7 +162,7 @@ impl Flusher { if let Some(e) = st.error { return Err(e.error()); } - if done(&st) { + if done(&mut st) { return Ok(()); } st = self @@ -172,7 +181,7 @@ impl Flusher { } let stalled = timer.poll(&progress) == Stall::Expired; st = self.lock(); - if stalled && !done(&st) { + if stalled && !done(&mut st) { tracing::error!( target: "freemkv::io", stall_s = self.timing.stall.as_secs(), @@ -239,3 +248,85 @@ fn run(file: File, ops: &dyn FlushOps, shared: &Shared, timing: FlushTiming) { shared.cv.notify_all(); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + struct Instant0; + impl FlushOps for Instant0 { + fn chunk(&self, _: &File) -> io::Result<()> { + Ok(()) + } + fn range(&self, _: &File, _: u64, _: u64) -> Option> { + None + } + fn finish(&self, _: &File) -> io::Result<()> { + Ok(()) + } + fn sample(&self) -> Option { + None + } + } + + // Bytes past `requested` (left over after the chunk shrank) must be handed to the worker + // by a blocked writer, not waited on until the stall latches `SyncTimeout`. + #[test] + fn wait_room_requests_unrequested_bytes() { + let file = tempfile::tempfile().unwrap(); + let timing = FlushTiming { + stall: Duration::from_millis(400), + slow_chunk: Duration::from_secs(10), + chunk_min: 100, + chunk_max: 100, + sample_every: Duration::from_millis(50), + }; + let f = Flusher::spawn( + &file, + Arc::new(Instant0), + timing, + FlushProgress::default(), + 0, + ) + .unwrap(); + f.wait_room(10_000, None).unwrap(); + assert!(f.error().is_none()); + } + + // Unrequested bytes appearing after the writer blocked (the chunk shrank mid-wait) must + // still be handed over; a one-shot check before the wait idles the worker to a stall. + #[test] + fn wait_room_requests_bytes_unrequested_while_blocked() { + let file = tempfile::tempfile().unwrap(); + let timing = FlushTiming { + stall: Duration::from_millis(400), + slow_chunk: Duration::from_secs(10), + chunk_min: 100, + chunk_max: 100, + sample_every: Duration::from_millis(50), + }; + let f = Arc::new( + Flusher::spawn( + &file, + Arc::new(Instant0), + timing, + FlushProgress::default(), + 0, + ) + .unwrap(), + ); + // Once the worker idles: requested but not notified, so the writer blocks and hands + // nothing over; the bytes then become unrequested while it waits. + std::thread::sleep(Duration::from_millis(50)); + f.lock().requested = 10_000; + let writer = { + let f = f.clone(); + std::thread::spawn(move || f.wait_room(10_000, None)) + }; + std::thread::sleep(Duration::from_millis(50)); + f.lock().requested = 0; + writer.join().unwrap().unwrap(); + assert!(f.error().is_none()); + } +} diff --git a/src/io/writeback_file/linux.rs b/src/io/writeback_file/linux.rs index 1f4ef1e8..dd2589a6 100644 --- a/src/io/writeback_file/linux.rs +++ b/src/io/writeback_file/linux.rs @@ -10,18 +10,23 @@ use std::fs::File; use std::os::unix::io::AsRawFd; +use crate::platform::fs_type::{FsType, detect_fd}; + /// Pre-reserve extents for `size_bytes` of upcoming sequential writes. /// Best-effort: a non-zero rc is logged but not propagated, since the -/// caller would just continue with the unreserved file anyway. -pub(super) fn preallocate(file: &File, size_bytes: u64) { +/// caller would just continue with the unreserved file anyway. True if extents were +/// reserved on a detected local filesystem, where releasing them at close is cheap. +pub(super) fn preallocate(file: &File, size_bytes: u64) -> bool { // FALLOC_FL_KEEP_SIZE keeps the reported file size at 0 (writes grow it normally) // while pre-reserving extents. Clamp to `off_t` range; an unchecked `as i64` // cast would wrap a >= 2^63 size to a negative length (EINVAL no-op). let len = i64::try_from(size_bytes).unwrap_or(i64::MAX); + // SAFETY: a valid borrowed fd; the kernel reads no user memory. let rc = unsafe { libc::fallocate(file.as_raw_fd(), libc::FALLOC_FL_KEEP_SIZE, 0, len) }; tracing::debug!( target: "mux", "WritebackFile fallocate size_hint={size_bytes} rc={rc} ok={}", rc == 0 ); + rc == 0 && detect_fd(file.as_raw_fd()) == FsType::Local } diff --git a/src/io/writeback_file/macos.rs b/src/io/writeback_file/macos.rs index 0cefdec1..739e24f1 100644 --- a/src/io/writeback_file/macos.rs +++ b/src/io/writeback_file/macos.rs @@ -12,7 +12,7 @@ use crate::io::platform_macos::{ F_ALLOCATEALL, F_ALLOCATECONTIG, F_PEOFPOSMODE, F_PREALLOCATE, Fstore, }; -pub(super) fn preallocate(file: &File, size_bytes: u64) { +pub(super) fn preallocate(file: &File, size_bytes: u64) -> bool { // Clamp to the signed `off_t` range; an unchecked `as off_t` cast // would wrap a >= 2^63 size to a negative length. let len = i64::try_from(size_bytes).unwrap_or(i64::MAX) as libc::off_t; @@ -24,10 +24,12 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) { fst_bytesalloc: 0, }; // First attempt: contiguous. + // SAFETY: a valid borrowed fd and a live stack `Fstore`. let mut rc = unsafe { libc::fcntl(file.as_raw_fd(), F_PREALLOCATE, &mut fst) }; if rc == -1 { // Fall back: drop the contiguous hint, allow scattered extents. fst.fst_flags = F_ALLOCATEALL; + // SAFETY: as above. rc = unsafe { libc::fcntl(file.as_raw_fd(), F_PREALLOCATE, &mut fst) }; } tracing::debug!( @@ -36,4 +38,6 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) { fst.fst_bytesalloc, rc != -1 ); + // Not released at close here (as before): the trim is Linux KEEP_SIZE only. + false } diff --git a/src/io/writeback_file/mod.rs b/src/io/writeback_file/mod.rs index b280fe55..921cb84e 100644 --- a/src/io/writeback_file/mod.rs +++ b/src/io/writeback_file/mod.rs @@ -75,6 +75,8 @@ pub struct WritebackFile { ops: Arc, timing: FlushTiming, halt: Option, + /// A size hint reserved extents past EOF on a local FS; released at `sync_all` or drop. + hinted: bool, } impl WritebackFile { @@ -113,6 +115,7 @@ impl WritebackFile { ops, timing, halt: None, + hinted: false, }) } @@ -216,8 +219,27 @@ impl WritebackFile { /// extent allocation is preallocated. pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result { let file = File::create(path)?; - platform::preallocate(&file, size_bytes); - Self::new(file) + let reserved = platform::preallocate(&file, size_bytes); + let mut w = Self::new(file)?; + w.hinted = reserved; + Ok(w) + } + + // Release the KEEP_SIZE reservation past EOF (a same-length truncate); `hinted` is set + // only on a detected local FS. Skipped once degraded or failed: the setattr could then be + // a halt-blind full flush. A failure only warns. + fn release_reservation(&mut self) { + let clean = self.flusher.is_none() && self.check_writeback().is_ok(); + if !std::mem::take(&mut self.hinted) || self.pipeline.needs_flusher() || !clean { + return; + } + if let Err(e) = self + .file + .metadata() + .and_then(|m| self.file.set_len(m.len())) + { + tracing::warn!(target: "mux", error = %e, "WritebackFile size-hint reservation kept"); + } } /// Open an existing file at `path` for writing (no truncation) and @@ -265,7 +287,12 @@ impl WritebackFile { // WAIT_AFTER consumed it, so fsync can return 0 over lost data. match self.pipeline.error() { Some(e) => Err(e), - None => synced, + None => { + if synced.is_ok() { + self.release_reservation(); + } + synced + } } } @@ -363,6 +390,8 @@ impl Drop for WritebackFile { if let Some(e) = self.pipeline.error() { tracing::error!(target: "mux", error = %e, "WritebackFile dropped with a writeback error"); } + // Muxers close via `flush` + drop, never `sync_all`: release the reservation here too. + self.release_reservation(); } } @@ -461,6 +490,45 @@ mod tests { assert_eq!(read_back(&p), b"durable-tail"); } + // The page cache serves the bytes back whether or not a durable sync ran, so count the + // final flush: `finish()` on a boxed sink must reach it (the trait default does not). + #[test] + fn finish_through_trait_object_runs_the_durable_sync() { + use crate::io::sink::RandomAccessSink; + use std::sync::atomic::{AtomicUsize, Ordering}; + #[derive(Default)] + struct CountFinish(AtomicUsize); + impl FlushOps for CountFinish { + fn chunk(&self, _: &File) -> io::Result<()> { + Ok(()) + } + fn range(&self, _: &File, _: u64, _: u64) -> Option> { + None + } + fn finish(&self, _: &File) -> io::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn sample(&self) -> Option { + None + } + } + let ops = Arc::new(CountFinish::default()); + let w = WritebackFile::with_flush_ops( + tempfile::tempfile().unwrap(), + ops.clone(), + FlushTiming::default(), + ) + .unwrap(); + let mut boxed: Box = Box::new(w); + boxed.write_all(b"durable-tail").unwrap(); + boxed.finish().unwrap(); + assert!( + ops.0.load(Ordering::SeqCst) > 0, + "finish() skipped the final flush" + ); + } + // A writeback error the pipeline latched (Linux: a failed WAIT_AFTER already // consumed it, so fsync returns 0) must fail sync_all and every later write. #[test] @@ -530,6 +598,8 @@ mod tests { // Seek to the current end (offset 4) — a no-move seek. let off = w.seek(SeekFrom::Start(4)).unwrap(); assert_eq!(off, 4); + // A no-move seek must not reset the pipeline's chunk tracking. + assert_eq!(w.seek_count, 0, "redundant seek counted as a boundary"); w.write_all(b"BBBB").unwrap(); w.sync_all().unwrap(); drop(w); @@ -628,6 +698,67 @@ mod tests { assert_eq!(&bytes, b"hello"); } + // The size-hint reservation past EOF must be released once writing ends. + #[cfg(target_os = "linux")] + #[test] + fn size_hint_reservation_is_released_after_sync() { + use std::os::unix::fs::MetadataExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("hint-trim.bin"); + let mut w = WritebackFile::create_with_size_hint(&p, 64 * 1024 * 1024).unwrap(); + w.write_all(b"hello").unwrap(); + w.sync_all().unwrap(); + let blocks = std::fs::metadata(&p).unwrap().blocks(); + assert!(blocks < 1024, "reservation kept: {blocks} 512-byte blocks"); + } + + // The mux path (resolve.rs `writeback_file`): BufWriter over the hinted file, closed by + // flush + drop only, never `sync_all`. The reservation must still be released. + #[cfg(target_os = "linux")] + #[test] + fn size_hint_reservation_is_released_on_mux_close() { + use std::os::unix::fs::MetadataExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("hint-mux.bin"); + let f = WritebackFile::create_with_size_hint(&p, 64 * 1024 * 1024).unwrap(); + let mut w = std::io::BufWriter::with_capacity(64 * 1024, f); + w.write_all(&[7u8; 100 * 1024]).unwrap(); + w.flush().unwrap(); + drop(w); + assert_eq!(std::fs::metadata(&p).unwrap().len(), 100 * 1024); + let blocks = std::fs::metadata(&p).unwrap().blocks(); + assert!(blocks < 4096, "reservation kept: {blocks} 512-byte blocks"); + } + + // The reservation is released only after a successful sync: a failed (or halted) sync + // must not truncate, as that setattr is a halt-blind full flush on NFS. + #[test] + fn failed_sync_keeps_size_hint_reservation() { + struct FailFinish; + impl FlushOps for FailFinish { + fn chunk(&self, _: &File) -> io::Result<()> { + Ok(()) + } + fn range(&self, _: &File, _: u64, _: u64) -> Option> { + None + } + fn finish(&self, _: &File) -> io::Result<()> { + Err(io::Error::from_raw_os_error(5)) + } + fn sample(&self) -> Option { + None + } + } + let file = tempfile::tempfile().unwrap(); + let mut w = + WritebackFile::with_flush_ops(file, Arc::new(FailFinish), FlushTiming::default()) + .unwrap(); + w.hinted = true; + w.write_all(b"partial").unwrap(); + assert!(w.sync_all().is_err(), "the final flush failed"); + assert!(w.hinted, "reservation released before a successful sync"); + } + // `sync_all` is idempotent: calling it twice, then Drop (also // finalizes), must not corrupt data or panic. #[test] diff --git a/src/io/writeback_file/other.rs b/src/io/writeback_file/other.rs index 71bfa8ca..0953feca 100644 --- a/src/io/writeback_file/other.rs +++ b/src/io/writeback_file/other.rs @@ -5,9 +5,10 @@ use std::fs::File; -pub(super) fn preallocate(_file: &File, size_bytes: u64) { +pub(super) fn preallocate(_file: &File, size_bytes: u64) -> bool { tracing::debug!( target: "mux", "WritebackFile preallocate size_hint={size_bytes} skipped (no impl on this target)" ); + false } diff --git a/src/io/writeback_file/windows.rs b/src/io/writeback_file/windows.rs index 9789e2e2..02677391 100644 --- a/src/io/writeback_file/windows.rs +++ b/src/io/writeback_file/windows.rs @@ -6,9 +6,10 @@ use std::fs::File; -pub(super) fn preallocate(_file: &File, size_bytes: u64) { +pub(super) fn preallocate(_file: &File, size_bytes: u64) -> bool { tracing::debug!( target: "mux", "WritebackFile preallocate size_hint={size_bytes} skipped (no-op on windows)" ); + false } diff --git a/src/keys/arrival.rs b/src/keys/arrival.rs index 0e256548..d619602c 100644 --- a/src/keys/arrival.rs +++ b/src/keys/arrival.rs @@ -12,6 +12,7 @@ use crate::aacs::content::{ ALIGNED_UNIT_LEN, aacs_unit_encrypted, aacs_unit_on_grid, clear_copy_permission_indicator, decrypt_unit, is_clean, }; +use crate::consts::SECTOR_BYTES; use crate::disc::ContentFormat; use crate::error::{Error, Result}; use crate::sector::SectorSource; @@ -132,11 +133,13 @@ impl Arrival { let (id, candidate) = self.pieces[span.3]; let slot = match self.cache.get(id) { Some(Proof::Proven(s)) => s, - Some(Proof::Provisional(s)) => { + Some(Proof::Provisional(s, from)) => { // KU §2.4: the next readable unit confirms a provisional key or stops; // one no held key opens while the key opens a batch partner is damage. if self.opens(&buf[range.clone()], &unit_keys[s].1) { - self.cache.set(id, Proof::Proven(s)); + if at != from { + self.cache.set(id, Proof::Proven(s)); + } s } else if self.is_damage( &buf[range.clone()], @@ -269,7 +272,7 @@ impl Arrival { None if !any_partner => { // No readable partner: provisional one-unit proof, false-accept ≈ pool × 1e-5. tracing::debug!(target: "freemkv::keys", lba = at, "provisional one-unit proof"); - self.cache.set(id, Proof::Provisional(openers[0])); + self.cache.set(id, Proof::Provisional(openers[0], at)); Ok(Some(openers[0])) } None => Err(self.stop(at, "no held key opens the unit and a partner")), @@ -313,7 +316,7 @@ impl Arrival { let (Ok(lba), Ok(count)) = (u32::try_from(from), u16::try_from(to - from)) else { return Vec::new(); }; - let mut side = vec![0u8; count as usize * 2048]; + let mut side = vec![0u8; count as usize * SECTOR_BYTES]; match inner.read_sectors(lba, count, &mut side, false) { Ok(n) => side[..n.min(side.len())] .chunks(ALIGNED_UNIT_LEN) diff --git a/src/keys/fmts.rs b/src/keys/fmts.rs index d15dbe38..c5cbeeac 100644 --- a/src/keys/fmts.rs +++ b/src/keys/fmts.rs @@ -3,12 +3,15 @@ use crate::aacs::content::ALIGNED_UNIT_LEN; use crate::aacs::segment::{Segment, clip_byte_to_lba, parse_individual_segments}; +use crate::consts::SECTOR_BYTES_U64; use crate::decrypt::Phase; use crate::disc::{ContentFormat, Extent}; use crate::error::{Error, Result}; use crate::halt::Halt; use crate::sector::SectorSource; +use crate::whole_disc::UNIT; use std::collections::HashMap; +use std::io; /// Units per anchor or phase batch: the key service's sample floor. const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS; @@ -26,7 +29,10 @@ pub(crate) struct Layout { pub(crate) unresolved: bool, } -/// Read the disc's forensic layout. `Ok(None)`: not FMTS (no or empty segment table). +// Bytes per source packet (the SPN unit). +const SPN_BYTES: u64 = 192; + +/// Read the disc's forensic layout. `Ok(None)`: not FMTS (no or empty segment table); an unparseable table is refused. pub(crate) fn layout( fs: &crate::udf::UdfFs, reader: &mut dyn SectorSource, @@ -36,19 +42,27 @@ pub(crate) fn layout( Err(Error::UdfNotFound { .. }) => return Ok(None), Err(e) => return Err(e), }; - let Some(segments) = parse_individual_segments(&tbl).filter(|s| !s.is_empty()) else { - return Ok(None); + let Some(segments) = parse_individual_segments(&tbl) else { + tracing::warn!(target: "freemkv::keys", "fmts: IndividualSegment.tbl present but unparseable"); + // Ok(None) would rip forensic units as base content: refuse. + return Err(Error::FmtsKeyMissing); }; - let Some(clip) = crate::mux::resolve::forensic_clip_extents(fs, reader)? else { + if segments.is_empty() { + return Ok(None); + } + let Some(clip) = forensic_clip_extents(fs, reader)? else { // No defensible anchor for the segment byte space: refuse rather than guess. tracing::warn!(target: "freemkv::keys", "fmts: forensic clip not identifiable"); return Err(Error::FmtsKeyMissing); }; - let segments = crate::mux::resolve::filter_addressable_segments(segments, &clip); + let segments = filter_addressable_segments(segments, &clip); let mut ranges = Vec::with_capacity(segments.len()); let mut unresolved = false; for seg in &segments { - let (start, end) = (seg.start_spn as u64 * 192, (seg.end_spn as u64 + 1) * 192); + let (start, end) = ( + seg.start_spn as u64 * SPN_BYTES, + (seg.end_spn as u64 + 1) * SPN_BYTES, + ); let (Some(a), Some(b)) = ( clip_byte_to_lba(&clip, start), clip_byte_to_lba(&clip, end.saturating_sub(1)), @@ -56,7 +70,10 @@ pub(crate) fn layout( unresolved = true; continue; }; - if seg.start_spn <= seg.end_spn && b >= a && (b - a) as u64 == (end - 1 - start) / 2048 { + if seg.start_spn <= seg.end_spn + && b >= a + && (b - a) as u64 == (end - 1 - start) / SECTOR_BYTES_U64 + { ranges.push((a, b + 1, seg.index)); } else { unresolved = true; @@ -88,10 +105,10 @@ fn read_unit( seg: &Segment, index: usize, ) -> Option> { - let byte = seg.start_spn as u64 * 192 + index as u64 * ALIGNED_UNIT_LEN as u64; + let byte = seg.start_spn as u64 * SPN_BYTES + index as u64 * ALIGNED_UNIT_LEN as u64; let lba = clip_byte_to_lba(clip, byte)?; let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; - match reader.read_sectors(lba, 3, &mut unit, false) { + match reader.read_sectors(lba, UNIT as u16, &mut unit, false) { Ok(n) if n == ALIGNED_UNIT_LEN => Some(unit), _ => None, } @@ -183,17 +200,13 @@ pub(crate) fn phases( // Decide a forensic index's decrypt phase from clean-sample counts of its EVEN vs ODD aligned // units under that index's key. Extracted for unit-testing. -fn resolve_tie_phase( - even_clean: usize, - odd_clean: usize, -) -> std::io::Result { +fn resolve_tie_phase(even_clean: usize, odd_clean: usize) -> Option { + use std::cmp::Ordering; match even_clean.cmp(&odd_clean) { - std::cmp::Ordering::Greater => Ok(crate::decrypt::Phase::Even), - std::cmp::Ordering::Less => Ok(crate::decrypt::Phase::Odd), - std::cmp::Ordering::Equal if even_clean == 0 => { - Err(crate::error::Error::FmtsKeyMissing.into()) - } - std::cmp::Ordering::Equal => Ok(crate::decrypt::Phase::Even), + Ordering::Greater => Some(Phase::Even), + Ordering::Less => Some(Phase::Odd), + Ordering::Equal if even_clean == 0 => None, + Ordering::Equal => Some(Phase::Even), } } @@ -252,7 +265,7 @@ pub(crate) fn probe_index_phase( // A clean parity or padding tie (even == odd > 0) resolves the phase; // even == odd == 0 is this segment's wrong-key signature, but a // different same-index segment could still anchor, so keep trying. - if let Ok(phase) = resolve_tie_phase(even, odd) { + if let Some(phase) = resolve_tie_phase(even, odd) { return IndexProbe::Phase(phase); } } @@ -263,6 +276,94 @@ pub(crate) fn probe_index_phase( } } +// Tag for an FMTS forensic index key banked into the key pool (base + slot); separates disc +// BASE CPS unit keys (< this) from forensic ones (>= this). +pub(crate) const FMTS_POOL_TAG_BASE: u32 = 1 << 24; + +// FMTS forensic feature clip's own extents — the byte space every `IndividualSegment.tbl` SPN +// is relative to — or None if not exactly one such clip. +pub(crate) fn forensic_clip_extents( + udf: &crate::udf::UdfFs, + reader: &mut dyn SectorSource, +) -> io::Result>> { + let Some(dir) = udf.find_dir("/BDMV/STREAM") else { + return Ok(None); + }; + let mut names = dir + .entries + .iter() + .filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".fmts")) + .map(|e| e.name.clone()); + let Some(name) = names.next() else { + return Ok(None); + }; + if names.next().is_some() { + tracing::warn!(target: "freemkv::keys", "fmts: more than one forensic clip on the disc — segment byte space is ambiguous"); + return Ok(None); + } + // Addressing variant: these extents are a byte-space map for the forensic + // segment table (`clip_byte_to_lba`), not a read plan — an unrecorded + // extent must stay in place here or every later segment offset shifts. + let exts: Vec = udf + .file_extents_addressing(reader, &format!("/BDMV/STREAM/{name}")) + .map_err(io::Error::from)? + .into_iter() + .filter(|&(lba, sectors)| lba > 0 && sectors > 0) + .map(|(start_lba, sector_count)| crate::disc::Extent { + start_lba, + sector_count, + }) + .collect(); + Ok((!exts.is_empty()).then_some(exts)) +} + +// Keep only forensic segments addressable within the FORENSIC CLIP's extents; stale/foreign +// records past the clip's end are dropped. Extracted from resolve_fmts_key_map for direct +// testing. +pub(crate) fn filter_addressable_segments( + segments: Vec, + extents: &[crate::disc::Extent], +) -> Vec { + segments + .into_iter() + .filter(|s| { + crate::aacs::segment::clip_byte_to_lba(extents, s.start_spn as u64 * 192).is_some() + }) + .collect() +} + +// Back-fill LBA gaps NOT covered by forensic segment ranges with the base Unit Key, so the map +// is a COMPLETE positive list over the title's content extents. +pub(crate) fn fill_base_key_gaps( + extents: &[crate::disc::Extent], + forensic_ranges: &[(u32, u32, usize, crate::decrypt::Phase)], + base_idx: usize, +) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { + let cuts: Vec<(u32, u32)> = { + let mut c: Vec<(u32, u32)> = forensic_ranges.iter().map(|&(s, e, _, _)| (s, e)).collect(); + c.sort_unstable(); + c + }; + let mut fills = Vec::new(); + for ext in extents { + let end = ext.start_lba.saturating_add(ext.sector_count); + let mut cur = ext.start_lba; + for &(cs, ce) in &cuts { + if ce <= cur || cs >= end { + continue; // cut outside this extent + } + if cs > cur { + fills.push((cur, cs, base_idx, crate::decrypt::Phase::All)); + } + cur = cur.max(ce); + } + if cur < end { + fills.push((cur, end, base_idx, crate::decrypt::Phase::All)); + } + } + fills +} + #[cfg(test)] mod probe_tests { use crate::disc::ContentFormat; @@ -289,14 +390,8 @@ mod probe_tests { "even == odd > 0 → default Even" ); assert_eq!(super::resolve_tie_phase(1, 1).unwrap(), Phase::Even); - // Neither half clean (even == odd == 0): fail loud with FmtsKeyMissing. - let err = super::resolve_tie_phase(0, 0).unwrap_err(); - let expected = std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string(); - assert_eq!( - err.to_string(), - expected, - "even == odd == 0 → FmtsKeyMissing" - ); + // Neither half clean (even == odd == 0): no evidence. + assert_eq!(super::resolve_tie_phase(0, 0), None); } // ── Fix 1: FMTS phase-probe read-fault vs wrong-key distinction ───────── @@ -444,4 +539,225 @@ mod probe_tests { "a faulting first segment must not block resolving from the next same-index one" ); } + + // A READABLE but unclean same-index segment (wrong-key signature) must not + // stop the probe: a later same-index segment can still anchor the phase. + #[test] + fn probe_index_phase_falls_through_unclean_segment_to_next() { + use crate::decrypt::Phase; + let mut unclean = a_segment(1); + unclean.start_spn = 1; + let segs = vec![unclean, a_segment(1)]; + let key = [0x55u8; 16]; + let clean = encrypted_clean_unit(&key); + let other = encrypted_clean_unit(&[0x66u8; 16]); // decrypts to junk under `key` + let got = + super::probe_index_phase(&segs, 1, 8, 16, ContentFormat::BdTs, &key, |seg, unit| { + if seg.start_spn == 1 { + Some(other.clone()) + } else if unit % 2 == 0 { + Some(clean.clone()) + } else { + Some(vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]) + } + }); + assert_eq!( + got, + super::IndexProbe::Phase(Phase::Even), + "an unclean first segment must not end the probe as WrongKey" + ); + } +} + +#[cfg(test)] +mod fmts_helper_tests { + use super::*; + + // ── resolve_fmts_key_map decision helpers (behaviors flagged by audit) ── + + // BEHAVIOR 1 — segment filter: a segment mapping inside the title's extents is kept, + // past-clip dropped, all-outside -> empty. + #[test] + fn filter_addressable_segments_keeps_only_in_title_segments() { + use crate::aacs::segment::Segment; + // One extent covering clip bytes [0, 60*2048) = [0, 122880). + let extents = vec![Extent { + start_lba: 500, + sector_count: 60, + }]; + // start_spn 100 → clip byte 19200 < 122880 → maps to an LBA → KEEP. + let inside = Segment { + index: 1, + start_spn: 100, + end_spn: 199, + }; + // start_spn 1000 → clip byte 192000 >= 122880 → clip_byte_to_lba None → DROP. + let outside = Segment { + index: 2, + start_spn: 1000, + end_spn: 1099, + }; + let kept = super::filter_addressable_segments(vec![inside, outside], &extents); + assert_eq!(kept, vec![inside], "only the in-title segment survives"); + // All-outside → empty; `resolve_fmts_key_map` maps this to Ok(None). + assert!( + super::filter_addressable_segments(vec![outside], &extents).is_empty(), + "no addressable segment → empty (→ resolver Ok(None))" + ); + // Boundary: a segment whose start is the LAST clip byte still maps (Some); + // one exactly at the clip end (122880) does not. + let at_last = Segment { + index: 3, + start_spn: (122_879 / 192) as u32, // 639 → byte 122688 < 122880 + end_spn: 700, + }; + let at_end = Segment { + index: 4, + start_spn: (122_880 / 192) as u32, // 640 → byte 122880 == clip end → None + end_spn: 700, + }; + assert_eq!( + super::filter_addressable_segments(vec![at_last, at_end], &extents), + vec![at_last], + "start inside the clip is kept; start at/after the clip end is dropped" + ); + } + + // forensic + fills must cover every LBA of every extent EXACTLY once: no gap, no overlap. + fn assert_gapless( + extents: &[Extent], + forensic: &[(u32, u32, usize, crate::decrypt::Phase)], + fills: &[(u32, u32, usize, crate::decrypt::Phase)], + ) { + let mut spans: Vec<(u32, u32)> = forensic.iter().map(|&(s, e, _, _)| (s, e)).collect(); + spans.extend(fills.iter().map(|&(s, e, _, _)| (s, e))); + spans.sort_unstable(); + for w in spans.windows(2) { + assert!(w[0].1 <= w[1].0, "spans overlap: {:?} vs {:?}", w[0], w[1]); + } + for ext in extents { + let end = ext.start_lba + ext.sector_count; + for lba in ext.start_lba..end { + let covering = spans.iter().filter(|&&(s, e)| lba >= s && lba < e).count(); + assert_eq!( + covering, 1, + "LBA {lba} covered {covering}× (want exactly 1)" + ); + } + } + } + + // BEHAVIOR 3 — gap-fill range arithmetic, exhaustive over segment positions. + #[test] + fn fill_base_key_gaps_is_gapless_over_every_extent() { + use crate::decrypt::Phase::{All, Even, Odd}; + let base = 0usize; + + // No segments → the whole extent is base key. + let ext = vec![Extent { + start_lba: 100, + sector_count: 60, + }]; + let forensic: Vec<(u32, u32, usize, crate::decrypt::Phase)> = vec![]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!(fills, vec![(100, 160, base, All)], "no segments → all base"); + assert_gapless(&ext, &forensic, &fills); + + // One segment mid-extent → base | forensic | base, gapless. + let forensic = vec![(120, 130, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(100, 120, base, All), (130, 160, base, All)], + "mid-extent segment → leading + trailing base" + ); + assert_gapless(&ext, &forensic, &fills); + + // Segment at extent START → only a trailing base fill (no zero-length lead). + let forensic = vec![(100, 130, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(130, 160, base, All)], + "segment at start → no leading base, one trailing" + ); + assert_gapless(&ext, &forensic, &fills); + + // Segment at extent END → only a leading base fill (no zero-length trail). + let forensic = vec![(130, 160, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(100, 130, base, All)], + "segment at end → one leading base, no trailing" + ); + assert_gapless(&ext, &forensic, &fills); + + // Whole extent is one segment → no base fill at all, still gapless. + let forensic = vec![(100, 160, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert!( + fills.is_empty(), + "segment spans whole extent → no base fill" + ); + assert_gapless(&ext, &forensic, &fills); + + // Adjacent segments (touching, no gap between) → NO zero-length base range + // between them (guards the `cs > cur` off-by-one). + let forensic = vec![(110, 120, 5, Even), (120, 130, 6, Odd)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(100, 110, base, All), (130, 160, base, All)], + "adjacent segments → no zero-length fill between them" + ); + assert_gapless(&ext, &forensic, &fills); + + // Multi-extent: a segment mid-first-extent and one at the start of the + // second. Fills are per-extent and the union is gapless across both. + let exts = vec![ + Extent { + start_lba: 100, + sector_count: 60, + }, // [100, 160) + Extent { + start_lba: 1000, + sector_count: 40, + }, // [1000, 1040) + ]; + let forensic = vec![(120, 130, 5, Even), (1000, 1010, 7, Odd)]; + let fills = super::fill_base_key_gaps(&exts, &forensic, base); + assert_eq!( + fills, + vec![ + (100, 120, base, All), + (130, 160, base, All), + (1010, 1040, base, All), + ], + "each extent filled independently" + ); + assert_gapless(&exts, &forensic, &fills); + } + + // Forensic ranges arrive in table RECORD order, not LBA order; the gap walk's forward sweep + // needs them sorted. + #[test] + fn fill_base_key_gaps_sorts_cuts_that_arrive_in_table_order_not_lba_order() { + use crate::decrypt::Phase::{All, Even, Odd}; + let ext = vec![Extent { + start_lba: 100, + sector_count: 60, + }]; + // Table order: the HIGH segment recorded before the LOW one. + let forensic = vec![(140, 150, 6, Odd), (110, 120, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, 0); + assert_eq!( + fills, + vec![(100, 110, 0, All), (120, 140, 0, All), (150, 160, 0, All),], + "the gaps around BOTH forensic cuts must be filled, whatever order the \ + segment table listed them in" + ); + // The load-bearing invariant: exactly one range covers every content LBA. + assert_gapless(&ext, &forensic, &fills); + } } diff --git a/src/keys/mod.rs b/src/keys/mod.rs index 00092619..ed5fbc12 100644 --- a/src/keys/mod.rs +++ b/src/keys/mod.rs @@ -103,8 +103,9 @@ pub struct KeyResolution { pub(crate) enum Proof { /// The key opened the piece's unit and a partner unit. Proven(usize), - /// The key opened one unit with no readable partner; the next readable unit confirms it. - Provisional(usize), + /// The key opened the unit at this LBA with no readable partner; a different readable + /// unit confirms it (a re-read of the same unit is not independent). + Provisional(usize, u32), } /// The rip's in-memory record of on-arrival proofs (KU §2.4, J2, J16): which HELD key @@ -315,7 +316,7 @@ fn norm_hash(h: &str) -> String { crate::hex::strip_hex_prefix(h).to_ascii_lowercase() } -fn overlaps(a: &[(u32, u32)], s: u32, e: u32) -> bool { +pub(super) fn overlaps(a: &[(u32, u32)], s: u32, e: u32) -> bool { a.iter().any(|&(x, y)| x < e && s < y) } @@ -490,7 +491,7 @@ impl ResolvedKeySet { .map(|(s, k)| (s as u32 + 1, *k)) .collect(); for (j, k) in i.fmts_keys.iter().enumerate() { - let tag = crate::mux::resolve::FMTS_POOL_TAG_BASE.saturating_add(j as u32); + let tag = crate::keys::fmts::FMTS_POOL_TAG_BASE.saturating_add(j as u32); unit_keys.push((tag, *k)); } DecryptKeys::Aacs { @@ -530,7 +531,6 @@ impl ResolvedKeySet { // or over a source that cannot seek. fn caller_bug(what: &'static str) -> Error { tracing::error!(target: "freemkv::keys", what, "key set used incorrectly (caller bug)"); - debug_assert!(false, "key set used incorrectly: {what}"); Error::DecryptFailed } @@ -619,6 +619,10 @@ impl ResolvedKeySet { // extent is left to the on-arrival proof, which has no key to try, so the first // AACS-flagged unit stops with E7022 and clear units pass. For a mux given no AACS set. pub(crate) fn keyless_for(title: &crate::disc::DiscTitle, format: ContentFormat) -> Self { + Self(Arc::new(Self::keyless_inner(title, format))) + } + + fn keyless_inner(title: &crate::disc::DiscTitle, format: ContentFormat) -> Inner { let mut i = Inner::empty(); i.aacs = true; i.content_format = format; @@ -648,15 +652,14 @@ impl ResolvedKeySet { candidate: None, }); } - ResolvedKeySet(Arc::new(i)) + i } // `keyless_for` title `idx` of `disc`, carrying the disc's identity: its E7022 names the // disc, and it passes `is_for` on that disc (a Session mux given no set, over no key). pub(crate) fn keyless_for_disc(disc: &Disc, idx: usize) -> Option { let title = disc.titles.get(idx)?; - let mut set = Self::keyless_for(title, disc.content_format); - let i = Arc::get_mut(&mut set.0).expect("a fresh set has one owner"); + let mut i = Self::keyless_inner(title, disc.content_format); i.disc_hash = disc .aacs .as_ref() @@ -664,7 +667,7 @@ impl ResolvedKeySet { .unwrap_or_default(); i.format = disc.format; i.capacity = disc.capacity_sectors; - Some(set) + Some(Self(Arc::new(i))) } // A set keying `ranges` (`[start, end)`) with `key` as proven pieces: the mux tests' diff --git a/src/keys/resolve.rs b/src/keys/resolve.rs index 07ea849b..3411f384 100644 --- a/src/keys/resolve.rs +++ b/src/keys/resolve.rs @@ -2,7 +2,7 @@ use super::{ ArrivalPiece, ForensicState, Inner, KeyResolution, KeyScope, ProofCache, ResolveKeysOptions, - ResolvedKeySet, + ResolvedKeySet, overlaps, }; use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean}; use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace}; @@ -27,6 +27,9 @@ const RETRY_CAP: Duration = Duration::from_secs(8); const NO_ANSWER_WINDOW: Duration = Duration::from_secs(60); /// Encrypted units sent in one per-piece request, at most. const SAMPLE_CAP: usize = 32; +// Most keys taken from one source answer: real discs declare few CPS units; a source cannot +// flood the pool or starve a later source. +const MAX_POOL_KEYS: usize = 256; /// Time for the J13 retry: injected so tests use a fake clock and never sleep. pub(crate) trait Clock { @@ -143,7 +146,10 @@ impl Run<'_> { // Add keys to the pool (deduplicated): the pool only grows during `resolve`. fn add_keys(&mut self, keys: &[[u8; 16]], who: &'static str) { - for k in keys { + if keys.len() > MAX_POOL_KEYS { + tracing::warn!(target: "freemkv::keys", who, "source answer truncated: extra keys ignored"); + } + for k in keys.iter().take(MAX_POOL_KEYS) { if !self.pool.contains(k) { self.pool.push(*k); self.origin.push(who); @@ -322,10 +328,6 @@ fn sorted_ranges(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> { out } -fn overlaps(a: &[(u32, u32)], s: u32, e: u32) -> bool { - a.iter().any(|&(x, y)| x < e && s < y) -} - // The pieces of `scope`: each content file the scope touches (on its own unit grid, KS-1), // minus sectors an earlier file claimed (an SSIF re-lists m2ts sectors), then title extents // no file covers (anchored at the extent start). @@ -353,7 +355,10 @@ fn pieces(disc: &Disc, files: &[Vec<(u32, u32)>], sel: &[usize], whole: bool) -> let mut claimed: Vec<(u32, u32)> = Vec::new(); let mut out = Vec::new(); let push = |spans: Vec, out: &mut Vec| { - let ranges: Vec<(u32, u32)> = spans.iter().map(|&(s, n, _)| (s, s + n)).collect(); + let ranges: Vec<(u32, u32)> = spans + .iter() + .map(|&(s, n, _)| (s, s.saturating_add(n))) + .collect(); let titles = touches(&ranges); let rank = titles .iter() @@ -371,7 +376,10 @@ fn pieces(disc: &Disc, files: &[Vec<(u32, u32)>], sel: &[usize], whole: bool) -> }); }; for file in files { - let ends: Vec<(u32, u32)> = file.iter().map(|&(s, n)| (s, s + n)).collect(); + let ends: Vec<(u32, u32)> = file + .iter() + .map(|&(s, n)| (s, s.saturating_add(n))) + .collect(); if !whole && touches(&ends).is_empty() { continue; } @@ -455,12 +463,20 @@ fn probe( p.enc.push(buf.clone()); } } + // A gone source or a Stop is not a soft fault: report it, not a missing key. + Err(e) if fatal_read(&e) => return Err(e), _ => p.faults += 1, } } Ok(()) } +fn fatal_read(e: &Error) -> bool { + matches!(e, Error::Halted) + || e.is_source_terminated() + || (e.is_scsi_transport_failure() && !matches!(e, Error::IoError { .. })) +} + fn missing_error(scope: &KeyScope, disc: &Disc) -> Error { match scope { KeyScope::WholeDisc => Error::WholeDiscKeyMissing, @@ -628,7 +644,14 @@ fn resolve_hddvd( disc_hash: disc.aacs_disc_hash(), }); } - let files = crate::whole_disc::content_files(reader).unwrap_or_default(); + let files = match crate::whole_disc::content_files(reader) { + Ok(f) => f, + Err(e) if *scope == KeyScope::WholeDisc => return Err(e), + Err(e) => { + tracing::warn!(target: "freemkv::keys", error = %e, "no HD DVD file list: keying title extents"); + Vec::new() + } + }; let ps = pieces(disc, &files, sel, *scope == KeyScope::WholeDisc); if run.pool.is_empty() { let samples = disc.content_samples(reader, MIN_SAMPLE_UNITS); @@ -684,7 +707,10 @@ fn resolve_bd( Some(fs) => match crate::whole_disc::content_files_in(fs, reader) { Ok(f) => f, Err(e) if *scope == KeyScope::WholeDisc => return Err(e), - Err(_) => Vec::new(), + Err(e) => { + tracing::warn!(target: "freemkv::keys", error = %e, "no file list: keying title extents"); + Vec::new() + } }, None => Vec::new(), }; @@ -1000,7 +1026,7 @@ fn build( }) .collect(); // Base content around the forensic segments keeps the piece's own key (K-3). - ranges.extend(crate::mux::resolve::fill_base_key_gaps( + ranges.extend(crate::keys::fmts::fill_base_key_gaps( &extents, &seg_ranges, slot, diff --git a/src/keys/tests.rs b/src/keys/tests.rs index d5080638..c4c3acb6 100644 --- a/src/keys/tests.rs +++ b/src/keys/tests.rs @@ -147,6 +147,8 @@ struct Faulty { inner: MemSource, dead: Arc>>, halt_after: Option<(Halt, Arc>)>, + // Dead ranges fail as a gone source, not a media fault. + fatal: bool, } impl Faulty { @@ -155,6 +157,7 @@ impl Faulty { inner, dead: Arc::default(), halt_after: None, + fatal: false, } } fn kill(&self, start: u32, end: u32) { @@ -186,6 +189,9 @@ impl SectorSource for Faulty { .iter() .any(|&(s, e)| s < end && lba < e) { + if self.fatal { + return Err(Error::SourceTerminated); + } return Err(Error::DiscRead { sector: lba as u64, status: None, @@ -490,16 +496,9 @@ fn read( Ok(masked(&buf)) } -// A caller bug refuses with E7013 plus a debug assertion (KU §6): a panic in a debug -// build, `Err(E7013)` in a release build. +// A caller bug refuses with E7013 (KU §6), in debug and release builds alike. fn caller_bug(f: impl FnOnce() -> Result) { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { - Err(_) => { - #[cfg(not(debug_assertions))] - panic!("only a debug build asserts"); - } - Ok(r) => assert_eq!(code(r), crate::error::E_DECRYPT_FAILED), - } + assert_eq!(code(f()), crate::error::E_DECRYPT_FAILED); } const E7013: u16 = crate::error::E_DECRYPT_FAILED; @@ -1112,12 +1111,22 @@ fn on_arrival_proof() { let (b, n) = fx.file(1); src.kill(b, fx.unit(1, 5)); src.kill(fx.unit(1, 6), b + n); - let mut r = set.title_reader(&fx.disc, 0, src).unwrap(); + let mut r = set.title_reader(&fx.disc, 0, src.clone()).unwrap(); assert_eq!( read(&mut r, &fx, 1, 5, 1).unwrap(), fx.plain(fx.unit(1, 5), 1) ); - assert_eq!(set.proof_cache().get(b), Some(Proof::Provisional(1))); + assert_eq!( + set.proof_cache().get(b), + Some(Proof::Provisional(1, fx.unit(1, 5))) + ); + // A re-read of the proving unit is not independent confirmation. + let mut r2 = set.title_reader(&fx.disc, 0, src).unwrap(); + read(&mut r2, &fx, 1, 5, 1).unwrap(); + assert_eq!( + set.proof_cache().get(b), + Some(Proof::Provisional(1, fx.unit(1, 5))) + ); // (e) across 3 passes: a tail unit with permanently dead neighbours is decrypted every // pass; passes 2 and 3 reuse the ProofCache (shared by the set's clones): 0 side reads. @@ -1163,7 +1172,10 @@ fn on_arrival_proof() { src.kill(fx.unit(1, 6), b + n); let mut r = set.title_reader(&fx.disc, 0, src.clone()).unwrap(); read(&mut r, &fx, 1, 5, 1).expect("provisional under K1"); - assert_eq!(set.proof_cache().get(b), Some(Proof::Provisional(0))); + assert_eq!( + set.proof_cache().get(b), + Some(Proof::Provisional(0, fx.unit(1, 5))) + ); src.heal(); assert_eq!( code(read(&mut r, &fx, 1, 7, 1)), @@ -1266,7 +1278,7 @@ fn on_arrival_read_damage_neither_proves_nor_refutes() { ); assert_eq!( set.proof_cache().get(fx.file(1).0), - Some(Proof::Provisional(1)) + Some(Proof::Provisional(1, fx.unit(1, 5))) ); } @@ -1300,7 +1312,10 @@ fn on_arrival_a_garbled_head_keeping_sync_is_blanked_never_e7022() { src.kill(fx.unit(1, 6), b + n); let mut r = set.title_reader(&fx.disc, 0, src.clone()).unwrap(); read(&mut r, &fx, 1, 5, 1).expect("provisional under K2"); - assert_eq!(set.proof_cache().get(b), Some(Proof::Provisional(1))); + assert_eq!( + set.proof_cache().get(b), + Some(Proof::Provisional(1, fx.unit(1, 5))) + ); src.heal(); let mut want = fx.plain(fx.unit(1, 7), 3); want[..ALIGNED_UNIT_LEN].fill(0); @@ -1480,6 +1495,23 @@ fn fmts_fixture() -> Fx { fx } +/// A present but unparseable segment table is a refusal, never "not FMTS" (which would rip +/// forensic units as base content). +#[test] +fn fmts_malformed_segment_table_is_refused() { + let mut fx = fmts_fixture(); + let at = fx.file(2).0 as usize * 2048; + // record_size (bytes 6..8) no longer matches the record length. + fx.img.image[at + 6..at + 8].copy_from_slice(&15u16.to_be_bytes()); + let calls = Calls::default(); + let r = resolve( + &fx, + KeyScope::Titles(vec![2]), + &[Spec::keydb(&[K1, K2], &calls)], + ); + assert!(matches!(r, Err(Error::FmtsKeyMissing)), "{:?}", r.err()); +} + fn fmts_online(calls: &Calls) -> Spec { let mut s = Spec::online(&[K2], calls); s.fmts = vec![F1, F2]; @@ -1740,10 +1772,26 @@ fn resolve_retains_no_source_and_nothing_asks_after() { let mut buf = vec![0u8; 30 * 2048]; w.read_sectors(b, 30, &mut buf, true).unwrap(); } - let _ = crate::mux::mux_with_keys( + // The mux needs a stream to run at all; with none it returns E6009 before reading. + let mut title = fx.disc.titles[0].clone(); + title + .streams + .push(crate::disc::Stream::Video(crate::disc::VideoStream { + pid: 0x1011, + codec: crate::disc::Codec::Mpeg2, + resolution: crate::disc::Resolution::R1080p, + frame_rate: crate::disc::FrameRate::F23_976, + hdr: crate::disc::HdrFormat::Sdr, + color_space: crate::disc::ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })); + let r = crate::mux::mux_with_keys( crate::mux::MuxSource::Live { reader: Box::new(src.clone()), - title: fx.disc.titles[0].clone(), + title, format: ContentFormat::BdTs, }, Some(&set), @@ -1755,6 +1803,13 @@ fn resolve_retains_no_source_and_nothing_asks_after() { &Halt::new(), Arc::new(crate::mux::driver::NoopEvents), ); + // The fixture TS carries no muxable frames, so a mux that read every unit through the + // held keys ends E6008 (MkvInvalid); E6009 would mean it never read. + let e = r.expect_err("no muxable frames"); + assert_eq!( + crate::error::error_code(&e), + Some(crate::error::E_MKV_INVALID) + ); assert_eq!(Arc::strong_count(&f), 1); assert_eq!(calls.len(), asked, "nothing asks after resolve"); } @@ -2752,3 +2807,83 @@ fn whole_disc_reader_leaves_a_piece_with_an_opened_probe_to_arrival() { ); assert!(set.whole_disc_reader(&fx.disc, fx.source(), None).is_ok()); } + +/// A file extent near u32::MAX (untrusted UDF) must not overflow the piece math. +#[test] +fn pieces_survive_extents_near_the_lba_limit() { + let fx = two_units(); + let files = vec![vec![(u32::MAX - 4, 10)]]; + let spans = crate::keys::resolve::whole_disc_pieces(&fx.disc, &files); + assert!(!spans.is_empty()); +} + +/// The push-closure path (a title extent no file covers) survives an extent near u32::MAX. +#[test] +fn uncovered_title_extent_near_the_lba_limit_survives() { + let mut fx = two_units(); + fx.disc.titles[0].extents = vec![Extent { + start_lba: u32::MAX - 4, + sector_count: 10, + }]; + let spans = crate::keys::resolve::whole_disc_pieces(&fx.disc, &[]); + assert!(!spans.is_empty()); +} + +/// One source flooding the pool cannot starve a later source's real key. +#[test] +fn a_flooding_source_does_not_starve_a_later_source() { + let fx = two_units(); + let junk: Vec<[u8; 16]> = (0..300u32) + .map(|i| { + let mut k = [0u8; 16]; + k[..4].copy_from_slice(&(i + 1).to_be_bytes()); + k + }) + .collect(); + let calls = Calls::default(); + let mut later = Spec::keydb(&[K1, K2], &calls); + later.who = "later"; + let r = resolve( + &fx, + KeyScope::WholeDisc, + &[Spec::keydb(&junk, &calls), later], + ); + assert!(r.is_ok(), "{:?}", r.err()); +} + +/// A key service cannot flood the pool: keys past the cap are ignored. +#[test] +fn key_pool_is_capped() { + let fx = two_units(); + let mut keys = vec![K1, K2]; + keys.extend((0..300u32).map(|i| { + let mut k = [0u8; 16]; + k[..4].copy_from_slice(&(i + 1).to_be_bytes()); + k + })); + let calls = Calls::default(); + let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&keys, &calls)]).unwrap(); + assert!(format!("{set:?}").contains("keys_len: 256"), "{set:?}"); +} + +/// A gone source during probing is reported, not counted as a soft fault. +#[test] +fn probe_reports_a_terminated_source() { + let fx = two_units(); + let mut src = fx.source(); + src.fatal = true; + for i in 0..2 { + let (b, n) = fx.file(i); + src.kill(b, b + n); + } + let calls = Calls::default(); + let r = resolve_with( + &fx, + &mut src, + KeyScope::WholeDisc, + &[Spec::keydb(&[K1, K2], &calls)], + ResolveKeysOptions::default(), + &FakeClock::default(), + ); + assert!(matches!(r, Err(Error::SourceTerminated)), "{r:?}"); +} diff --git a/src/keysource.rs b/src/keysource.rs index 4473f9fc..55f3d9e1 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -368,6 +368,7 @@ pub trait KeySource { /// candidate against. /// Lives in the library, not a key-source crate: carving units is decryption *mechanism*. /// "Encrypted" is the AACS CPI (`buf[0] & 0xc0`), NOT the `is_clean` TS-sync heuristic. +/// A Stop ends sampling early with the partial set; callers must check their own `Halt`. pub fn read_encrypted_units( reader: &mut dyn crate::sector::SectorSource, title: &crate::disc::DiscTitle, @@ -381,6 +382,9 @@ pub fn read_encrypted_units( const PROBES_PER_EXTENT: u32 = 8; let mut out: Vec> = Vec::new(); + if n == 0 { + return out; + } for ext in &title.extents { let total_units = ext.sector_count / ALIGNED_UNIT_SECTORS; if total_units == 0 { @@ -410,8 +414,11 @@ pub fn read_encrypted_units( // `false` = no recovery retries; reader is the raw drive/file (no // decrypt decorator). A read error skips only THAT probe — it must not // abandon the rest of the extent (the old `break` blinded the sampler). - if reader.read_sectors(lba, count, &mut buf, false).is_err() { - continue; + match reader.read_sectors(lba, count, &mut buf, false) { + Ok(_) => {} + // A Stop ends sampling: every remaining probe would only re-hit the drive. + Err(crate::error::Error::Halted) => return out, + Err(_) => continue, } for i in 0..units_this as usize { let o = i * ALIGNED_UNIT_LEN; @@ -526,6 +533,7 @@ mod tests { assert_eq!(ctx.mkb().unwrap(), &[1, 2, 3]); assert_eq!(ctx.enc_title_keys().unwrap(), &[key_bytes]); assert_eq!(ctx.samples(2).unwrap().len(), 2, "samples truncates to n"); + assert_eq!(ctx.unit_key_ro(), &inputs.unit_key_ro[..]); // Non-zero VID → Some(vid). let mut inputs2 = inputs.clone(); @@ -534,6 +542,120 @@ mod tests { assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16]))); } + // The documented contract: garbage Unit_Key_RO parses to no keys, not an error. + #[test] + fn malformed_unit_key_ro_yields_an_empty_key_set() { + for garbage in [vec![0xFFu8; 7], vec![0xFF; 96], vec![0u8; 3]] { + let mut inputs = empty_inputs(); + inputs.unit_key_ro = garbage; + let ctx = DiscInputsCtx::new(&inputs); + assert!(ctx.enc_title_keys().unwrap().is_empty()); + } + } + + // A source scripted per read: `Ok` fills every unit CPI-set; errors are by call index. + struct ScriptedSource { + calls: Vec, + fail: fn(usize) -> Option, + } + impl crate::sector::SectorSource for ScriptedSource { + fn capacity_sectors(&self) -> u32 { + u32::MAX + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _r: bool, + ) -> crate::error::Result { + self.calls.push(lba); + if let Some(e) = (self.fail)(self.calls.len() - 1) { + return Err(e); + } + let bytes = count as usize * 2048; + for c in buf[..bytes].chunks_mut(crate::aacs::content::ALIGNED_UNIT_LEN) { + c.fill(0xAB); + c[0] = 0xC0; + } + Ok(bytes) + } + } + + fn title_at(start_lba: u32, units: u32) -> crate::disc::DiscTitle { + crate::disc::DiscTitle { + playlist: String::new(), + playlist_id: 0, + duration_secs: 0.0, + size_bytes: 0, + clips: Vec::new(), + streams: Vec::new(), + chapters: Vec::new(), + extents: vec![crate::disc::Extent { + start_lba, + sector_count: units * crate::aacs::content::ALIGNED_UNIT_SECTORS, + }], + content_format: crate::disc::ContentFormat::BdTs, + codec_privates: Vec::new(), + } + } + + // An ordinary read error skips that probe only; a Stop ends sampling at once. + #[test] + fn read_encrypted_units_skips_errors_but_stops_on_halt() { + let title = title_at(1000, 600); + let mut src = ScriptedSource { + calls: Vec::new(), + fail: |i| { + (i == 0).then_some(crate::error::Error::DiscRead { + sector: 0, + status: None, + sense: None, + }) + }, + }; + let got = read_encrypted_units(&mut src, &title, 3); + assert_eq!(got.len(), 3, "later probes still sampled after one error"); + + let mut src = ScriptedSource { + calls: Vec::new(), + fail: |_| Some(crate::error::Error::Halted), + }; + let got = read_encrypted_units(&mut src, &title, 3); + assert!(got.is_empty()); + assert_eq!(src.calls.len(), 1, "no probe after Halted"); + } + + // `n == 0` reads nothing and returns nothing. + #[test] + fn read_encrypted_units_zero_wanted_reads_nothing() { + let mut src = ScriptedSource { + calls: Vec::new(), + fail: |_| None, + }; + assert!(read_encrypted_units(&mut src, &title_at(1000, 600), 0).is_empty()); + assert!(src.calls.is_empty()); + } + + // Extent LBAs are untrusted: near u32::MAX the arithmetic saturates, never panics. + #[test] + fn read_encrypted_units_saturates_lba_near_u32_max() { + let mut src = ScriptedSource { + calls: Vec::new(), + fail: |_| { + Some(crate::error::Error::DiscRead { + sector: 0, + status: None, + sense: None, + }) + }, + }; + let title = title_at(u32::MAX - 10, 600); + assert!(read_encrypted_units(&mut src, &title, 3).is_empty()); + assert!(!src.calls.is_empty()); + assert!(src.calls.iter().all(|&l| l >= u32::MAX - 10)); + } + // ── fetch_unit_keys (the one shared fetch path) ─────────────────────────── fn empty_inputs() -> DiscInputs { diff --git a/src/labels/bdj_feature.rs b/src/labels/bdj_feature.rs index 7722f887..482cbd34 100644 --- a/src/labels/bdj_feature.rs +++ b/src/labels/bdj_feature.rs @@ -71,6 +71,8 @@ fn resolve_with_budget( } (budget == 0).then_some(Stop::BudgetSpent) }); + // A full candidate set means later jars were skipped: like a spent budget, abstain. + complete &= candidates.len() < MAX_CANDIDATES; match stop { Some(Stop::Tier1(hint)) => { tracing::info!(?hint, "bdj menu-walk: Tier-1 embedded manifest hint"); @@ -384,7 +386,10 @@ fn real_playlist_ids(udf: &UdfFs) -> HashSet { continue; } let stem = &e.name[..e.name.len() - ".mpls".len()]; - if let Ok(id) = stem.parse::() { + if stem.len() == 5 + && stem.bytes().all(|b| b.is_ascii_digit()) + && let Ok(id) = stem.parse::() + { out.insert(id); } } @@ -460,6 +465,11 @@ mod tests { choose_dominant(&[(800, 7000, 6), (801, 4000, 6)]), Some(800) ); + // Exactly DOMINANCE_RATIO (1.5x) still counts as dominant. + assert_eq!( + choose_dominant(&[(800, 6000, 6), (801, 4000, 6)]), + Some(800) + ); } #[test] @@ -732,6 +742,23 @@ mod tests { assert_eq!(hint.filename.as_deref(), Some("00800.mpls")); } + #[test] + fn tier2_abstains_when_the_candidate_cap_truncates_the_harvest() { + // 1100 distinct locators overrun MAX_CANDIDATES: the field is partial, so no hint. + let strings: Vec = (0..1100).map(|i| format!("{i:05}.mpls")).collect(); + let refs: Vec<&str> = strings.iter().map(String::as_str).collect(); + let jar = build_jar(&[("com/studio/MainXlet.class", build_class(&refs))]); + let (mut disc, udf) = build_disc( + vec![ + ("00800.mpls", build_mpls(7000, 6)), + ("00801.mpls", build_mpls(120, 2)), + ], + vec![("00000.jar", jar)], + vec![], + ); + assert_eq!(resolve(&mut disc, &udf, &[]), None); + } + #[test] fn tier2_abstains_when_candidate_not_a_real_playlist() { // The class points at 09999.mpls, which is not on the disc → no hit. diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs index 437b275c..1e72b69d 100644 --- a/src/labels/bdmt.rs +++ b/src/labels/bdmt.rs @@ -16,6 +16,21 @@ use std::collections::BTreeMap; // metadata; real files are a few KB. const MAX_BDMT_BYTES: u64 = 1024 * 1024; +// Retained title/description cap (bytes): bounds memory across up to 26^3 languages. +const MAX_BDMT_TEXT: usize = 1024; + +// Truncate to at most `MAX_BDMT_TEXT` bytes on a char boundary. +fn cap_text(mut s: String) -> String { + if s.len() > MAX_BDMT_TEXT { + let mut end = MAX_BDMT_TEXT; + while !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + } + s +} + /// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`. /// /// All maps are keyed by 3-char ISO 639-2 language code (e.g. @@ -131,12 +146,13 @@ pub(crate) type BdmtFields = (String, Option, Option<(u32, u32)>); // Parse one bdmt_.xml document into (title, description?, disc_set?). None if no title // could be located. pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option { - let title = extract_title(xml_text)?; + let title = cap_text(extract_title(xml_text)?); // xml::text already returns a trimmed string (see xml::text), so the // description is only filtered for emptiness and XML-fragment noise. let description = xml::text(xml_text, "description") .filter(|s| !s.is_empty()) - .filter(|s| !looks_like_xml(s)); + .filter(|s| !looks_like_xml(s)) + .map(cap_text); let disc_set = extract_disc_set(xml_text); Some((title, description, disc_set)) } @@ -168,6 +184,7 @@ fn extract_title(xml_text: &str) -> Option { for tag in ["name", "title"] { if let Some(s) = xml::text(xml_text, tag) && !s.is_empty() + && !looks_like_xml(&s) { return Some(s); } @@ -185,14 +202,13 @@ fn extract_title(xml_text: &str) -> Option { None } -/// Extract `(discNumber, numSets)` if both are present and parse as +/// Extract `(setNumber, numSets)` if both are present and parse as /// `u32`. Accepts either `` or `` for /// the denominator (both forms appear in the wild). fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> { - let n = xml::text(xml_text, "discNumber")? - .trim() - .parse::() - .ok()?; + // Real discs carry ; is kept as a fallback. + let num = |tag| xml::text(xml_text, tag).and_then(|t| t.trim().parse::().ok()); + let n = num("setNumber").or_else(|| num("discNumber"))?; let total = xml::text(xml_text, "numSets") .or_else(|| xml::text(xml_text, "numberOfSets"))? .trim() @@ -211,6 +227,35 @@ fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> { mod tests { use super::*; + #[test] + fn title_container_markup_is_not_a_title() { + let xml = "1"; + assert_eq!(parse_bdmt_xml(xml), None); + } + + #[test] + fn set_number_is_read() { + let xml = + "X32"; + assert_eq!(parse_bdmt_xml(xml).expect("parse").2, Some((2, 3))); + } + + #[test] + fn non_numeric_set_number_falls_back_to_disc_number() { + let xml = "X3\ + two2"; + assert_eq!(parse_bdmt_xml(xml).expect("parse").2, Some((2, 3))); + } + + #[test] + fn long_title_and_description_are_capped() { + let big = "é".repeat(5000); + let xml = format!("{big}{big}"); + let (t, d, _) = parse_bdmt_xml(&xml).expect("parse"); + assert!(t.len() <= MAX_BDMT_TEXT && !t.is_empty()); + assert!(d.expect("desc").len() <= MAX_BDMT_TEXT); + } + #[test] fn extract_simple_title() { // Minimal Paramount-style document: as the title @@ -331,6 +376,83 @@ mod tests { assert_eq!(set, None); } + // A UDF with `/BDMV/META/DL/` holding `files` (name, contents), or no DL dir when `dl` is false. + fn disc_with_dl(files: &[(&str, Vec)], dl: bool) -> (crate::udf::fixture::MemDisc, UdfFs) { + use crate::udf::fixture::*; + let dl_files = files + .iter() + .enumerate() + .map(|(i, (n, c))| file_with(n, 30 + i as u32, 100 + 2_000 * i as u32, c.clone(), true)) + .collect(); + let dir = |name: &str, icb, data, files, subdirs| DirSpec { + name: name.into(), + icb_lba: icb, + dir_data_lba: data, + files, + subdirs, + }; + let leaf = if dl { + dir("DL", 16, 17, dl_files, vec![]) + } else { + dir("OTHER", 16, 17, dl_files, vec![]) + }; + let meta = dir("META", 14, 15, vec![], vec![leaf]); + let bdmv = dir("BDMV", 12, 13, vec![], vec![meta]); + let root = dir("", 10, 11, vec![], vec![bdmv]); + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + (disc, udf) + } + + #[test] + fn parse_aggregates_languages_and_first_disc_set_wins() { + let xml = |name: &str, set: u32| { + format!( + "{name}{set}\ + 2" + ) + .into_bytes() + }; + let mut fra = xml("Titre", 2); + fra.extend_from_slice(b"Suite."); + let mut big = xml("Too Big", 1); + big.resize(MAX_BDMT_BYTES as usize + 1, b' '); + let (mut disc, udf) = disc_with_dl( + &[ + ("bdmt_eng.xml", xml("Title", 1)), + ("bdmt_fra.xml", fra), + ("bdmt_deu.xml", big), + ("bdmt_xx.xml", xml("Bad Lang", 1)), + ("notes.xml", xml("Not BDMT", 1)), + ], + true, + ); + assert!(detect(&udf)); + let meta = parse(&mut disc, &udf).expect("titles found"); + let titles: Vec<_> = meta + .titles + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + assert_eq!(titles, [("eng", "Title"), ("fra", "Titre")]); + assert_eq!(meta.descriptions.len(), 1); + assert_eq!(meta.descriptions["fra"], "Suite."); + assert_eq!(meta.disc_number, Some((1, 2)), "the first file's set wins"); + } + + #[test] + fn parse_and_detect_are_none_without_bdmt_files() { + let (mut disc, udf) = disc_with_dl(&[("bdmt_eng.xml", b"".to_vec())], false); + assert!(!detect(&udf)); + assert!(parse(&mut disc, &udf).is_none()); + // A DL dir with only a title-less bdmt file: detected, but nothing to parse. + let (mut disc, udf) = disc_with_dl(&[("bdmt_eng.xml", b"".to_vec())], true); + assert!(detect(&udf)); + assert!(parse(&mut disc, &udf).is_none()); + } + #[test] fn multiple_languages_keyed_correctly() { // Drive parse_bdmt_xml from two synthetic XML blobs and aggregate diff --git a/src/labels/class_reader.rs b/src/labels/class_reader.rs index ce7d4f36..f7c8ae06 100644 --- a/src/labels/class_reader.rs +++ b/src/labels/class_reader.rs @@ -8,11 +8,6 @@ //! No external deps beyond `std`. No `unsafe`. No panics on malformed input — every parse fault //! is a typed [`Error`]. -// Foundation module — public API is staged for `labels::deluxe` (bytecode -// walker) and `labels::dbp`'s refactor onto the constant-pool iterator. -// The dead-code allow comes off once those callers land. -#![allow(dead_code)] - const CLASS_MAGIC: u32 = 0xCAFEBABE; // --------------------------------------------------------------------------- @@ -20,6 +15,10 @@ const CLASS_MAGIC: u32 = 0xCAFEBABE; // --------------------------------------------------------------------------- #[derive(Debug)] +#[expect( + dead_code, + reason = "payloads are fault detail for Debug; callers match the variant" +)] pub enum Error { UnexpectedEof { needed: &'static str }, BadMagic(u32), @@ -42,6 +41,10 @@ pub type Result = std::result::Result; // --------------------------------------------------------------------------- #[derive(Debug, Clone)] +#[expect( + dead_code, + reason = "every JVMS 4.4 tag is decoded; label parsers read only some payloads" +)] pub enum CpInfo { /// Index 0 is unused per spec; the slot after Long/Double is also unused. Empty, @@ -130,6 +133,7 @@ impl ConstantPool { } } + #[cfg(test)] /// Resolve a `CONSTANT_String` entry to its underlying UTF-8. pub fn string(&self, index: u16) -> Option<&str> { match self.get(index)? { @@ -138,6 +142,7 @@ impl ConstantPool { } } + #[cfg(test)] pub fn integer(&self, index: u16) -> Option { match self.get(index)? { CpInfo::Integer(v) => Some(*v), @@ -145,6 +150,7 @@ impl ConstantPool { } } + #[cfg(test)] /// For `ldc` / `ldc_w` operands: resolve a constant-pool index to a /// best-effort string. Supports Utf8, String, Integer, Float, Long, /// Double, and Class. @@ -198,12 +204,12 @@ impl ConstantPool { }) } - #[inline] + #[cfg(test)] pub fn len(&self) -> usize { self.entries.len() } - #[inline] + #[cfg(test)] pub fn is_empty(&self) -> bool { self.entries.is_empty() } @@ -224,6 +230,10 @@ pub struct MemberRef<'a> { // ClassFile + Member + Attribute // --------------------------------------------------------------------------- +#[expect( + dead_code, + reason = "parsed in full per JVMS 4.1; label parsers read the pool and methods" +)] pub struct ClassFile { pub minor_version: u16, pub major_version: u16, @@ -237,6 +247,7 @@ pub struct ClassFile { pub attributes: Vec, } +#[expect(dead_code, reason = "parsed in full per JVMS 4.5/4.6")] pub struct Member { pub access_flags: u16, pub name_index: u16, @@ -288,6 +299,7 @@ impl ClassFile { self.constant_pool.class_name(self.this_class) } + #[cfg(test)] pub fn super_class_name(&self) -> Option<&str> { self.constant_pool.class_name(self.super_class) } @@ -297,6 +309,7 @@ impl ClassFile { self.constant_pool.utf8(m.name_index) } + #[cfg(test)] pub fn member_descriptor<'a>(&'a self, m: &Member) -> Option<&'a str> { self.constant_pool.utf8(m.descriptor_index) } @@ -317,6 +330,10 @@ impl Member { pub struct CodeAttribute<'a> { pub max_stack: u16, + #[expect( + dead_code, + reason = "parsed per JVMS 4.7.3; the decoder tracks only the stack" + )] pub max_locals: u16, pub code: &'a [u8], } @@ -437,8 +454,7 @@ fn read_constant_pool(r: &mut Reader<'_>) -> Result { } // Decode JVM "modified UTF-8" (JVMS §4.4.7): like UTF-8 but U+0000 is 0xC0 0x80, and -// supplementary chars use a 3-byte-surrogate-pair encoding we don't bother handling — no label -// string needs it. +// supplementary chars are two 3-byte surrogate halves, combined here into one char. fn decode_modified_utf8(bytes: &[u8]) -> std::result::Result { let mut out = String::with_capacity(bytes.len()); let mut i = 0; @@ -480,14 +496,20 @@ fn decode_modified_utf8(bytes: &[u8]) -> std::result::Result { } let cp = (((b0 & 0x0F) as u32) << 12) | (((b1 & 0x3F) as u32) << 6) | ((b2 & 0x3F) as u32); - // Lone surrogates are valid in modified UTF-8 but invalid - // chars in Rust. For label data we'd never see one; treat - // as replacement char rather than error to stay robust. - match char::from_u32(cp) { - Some(c) => out.push(c), - None => out.push('\u{FFFD}'), - } i += 3; + // A high surrogate followed by a low one is one supplementary char. + if (0xD800..0xDC00).contains(&cp) + && let [0xED, b1 @ 0xB0..=0xBF, b2 @ 0x80..=0xBF, ..] = &bytes[i..] + { + let low = 0xD000 | (((b1 & 0x3F) as u32) << 6) | ((b2 & 0x3F) as u32); + if let Some(c) = char::from_u32(0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00)) { + out.push(c); + i += 3; + continue; + } + } + // A lone surrogate is invalid in Rust: replacement char, not an error. + out.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); } else { // 4-byte or higher: not valid in modified UTF-8. return Err(()); @@ -547,7 +569,8 @@ pub struct Instruction<'a> { } impl Instruction<'_> { - /// Mnemonic for this opcode (e.g. "ldc", "invokespecial"). + #[cfg(test)] + /// Mnemonic for the opcodes this reader models (e.g. "ldc"); "?" for others. pub fn name(&self) -> &'static str { opcode_name(self.opcode) } @@ -683,7 +706,6 @@ fn instruction_size(code: &[u8], pc: usize) -> Option { } // Opcode table: named opcode constants for the ones we walk in the parser. -#[allow(dead_code)] pub const NOP: u8 = 0x00; pub const ACONST_NULL: u8 = 0x01; pub const ICONST_M1: u8 = 0x02; @@ -708,7 +730,14 @@ pub const LSTORE: u8 = 0x37; pub const FSTORE: u8 = 0x38; pub const DSTORE: u8 = 0x39; pub const ASTORE: u8 = 0x3A; -pub const AASTORE: u8 = 0x53; +pub const IASTORE: u8 = 0x4F; +pub const SASTORE: u8 = 0x56; +pub const DCONST_1: u8 = 0x0F; +pub const POP: u8 = 0x57; +pub const POP2: u8 = 0x58; +pub const DUP: u8 = 0x59; +pub const GOTO: u8 = 0xA7; +pub const RETURN: u8 = 0xB1; pub const IINC: u8 = 0x84; pub const RET: u8 = 0xA9; pub const TABLESWITCH: u8 = 0xAA; @@ -729,6 +758,52 @@ pub const CHECKCAST: u8 = 0xC0; pub const INSTANCEOF: u8 = 0xC1; pub const WIDE: u8 = 0xC4; pub const MULTIANEWARRAY: u8 = 0xC5; +pub const LCONST_0: u8 = 0x09; +pub const LCONST_1: u8 = 0x0A; +pub const DCONST_0: u8 = 0x0E; +pub const LLOAD_0: u8 = 0x1E; +pub const LLOAD_3: u8 = 0x21; +pub const DLOAD_0: u8 = 0x26; +pub const DLOAD_3: u8 = 0x29; +pub const ALOAD_3: u8 = 0x2D; +pub const IALOAD: u8 = 0x2E; +pub const LALOAD: u8 = 0x2F; +pub const DALOAD: u8 = 0x31; +pub const SALOAD: u8 = 0x35; +pub const ASTORE_3: u8 = 0x4E; +pub const DUP_X1: u8 = 0x5A; +pub const DUP_X2: u8 = 0x5B; +pub const DUP2: u8 = 0x5C; +pub const DUP2_X1: u8 = 0x5D; +pub const DUP2_X2: u8 = 0x5E; +pub const SWAP: u8 = 0x5F; +pub const IADD: u8 = 0x60; +pub const DREM: u8 = 0x73; +pub const INEG: u8 = 0x74; +pub const DNEG: u8 = 0x77; +pub const ISHL: u8 = 0x78; +pub const LXOR: u8 = 0x83; +pub const I2L: u8 = 0x85; +pub const I2D: u8 = 0x87; +pub const L2D: u8 = 0x8A; +pub const F2L: u8 = 0x8C; +pub const F2D: u8 = 0x8D; +pub const D2L: u8 = 0x8F; +pub const I2S: u8 = 0x93; +pub const LCMP: u8 = 0x94; +pub const DCMPG: u8 = 0x98; +pub const IFEQ: u8 = 0x99; +pub const IFLE: u8 = 0x9E; +pub const IF_ICMPEQ: u8 = 0x9F; +pub const IF_ACMPNE: u8 = 0xA6; +pub const IRETURN: u8 = 0xAC; +pub const ARRAYLENGTH: u8 = 0xBE; +pub const ATHROW: u8 = 0xBF; +pub const MONITORENTER: u8 = 0xC2; +pub const MONITOREXIT: u8 = 0xC3; +pub const IFNULL: u8 = 0xC6; +pub const IFNONNULL: u8 = 0xC7; +pub const GOTO_W: u8 = 0xC8; /// Fixed instruction sizes (opcode + operand bytes). `None` means the /// opcode is either variable-length (see `instruction_size`) or @@ -827,6 +902,7 @@ const FIXED_SIZE: [Option; 256] = { t }; +#[cfg(test)] fn opcode_name(op: u8) -> &'static str { match op { 0x00 => "nop", @@ -992,6 +1068,8 @@ impl<'a> Reader<'a> { mod tests { use super::*; + const AASTORE: u8 = 0x53; + // Reader::slice takes an attacker-supplied length (JVMS u4/u2 field); // pos + len must not overflow/wrap past the bounds check and panic — // untrusted disc input must yield an EOF error, not a panic. @@ -1055,6 +1133,18 @@ mod tests { assert!(decode_modified_utf8(&[0x00]).is_err()); } + #[test] + fn modified_utf8_combines_surrogate_pair() { + // U+1F600 = D83D DE00, each half as a 3-byte sequence. + let s = decode_modified_utf8(&[0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80]).unwrap(); + assert_eq!(s, "\u{1F600}"); + // A lone high surrogate still degrades to U+FFFD. + assert_eq!( + decode_modified_utf8(&[0xED, 0xA0, 0xBD]).unwrap(), + "\u{FFFD}" + ); + } + #[test] fn modified_utf8_two_byte() { // U+00E9 'é' in the standard 2-byte modified-UTF-8 encoding @@ -1664,6 +1754,62 @@ mod tests { } } + #[test] + fn constant_pool_double_entry_occupies_two_slots_via_real_parse() { + // Same shape as the Long test with tag 6: the Utf8 must land at index 3. + let mut buf = vec![ + 0xCA, 0xFE, 0xBA, 0xBE, // magic + 0x00, 0x00, 0x00, 0x34, // minor / major + 0x00, 0x04, // cp_count = 4 (0=Empty,1=Double,2=Empty tail,3=Utf8) + 6, // Double tag + ]; + buf.extend_from_slice(&2.5f64.to_bits().to_be_bytes()); + buf.push(1); // Utf8 tag + buf.extend_from_slice(&6u16.to_be_bytes()); + buf.extend_from_slice(b"marker"); + buf.extend_from_slice(&[0; 8]); // access, this, super, interfaces_count + buf.extend_from_slice(&[0; 6]); // fields, methods, attributes counts + + let cf = ClassFile::parse(&buf).expect("well-formed synthetic class file"); + assert_eq!(cf.constant_pool.len(), 4); + assert_eq!(cf.constant_pool.utf8(3), Some("marker")); + assert_eq!(cf.constant_pool.utf8(2), None); + assert!(matches!(cf.constant_pool.get(1), Some(CpInfo::Double(v)) if *v == 2.5)); + } + + #[test] + fn instruction_size_switch_padding_depends_on_pc() { + // Padding is (pc+1+3)&!3 - pc - 1: 3,2,1,0 bytes for pc%4 = 0,1,2,3. + // pc=0 alone cannot tell `& !3` or `pc + 4` from the real alignment. + for pc in 0usize..8 { + let start = (pc + 1 + 3) & !3; + let mut code = vec![0u8; pc]; + code.push(TABLESWITCH); + code.resize(start, 0); + code.extend_from_slice(&[0; 4]); // default + code.extend_from_slice(&0i32.to_be_bytes()); // low + code.extend_from_slice(&1i32.to_be_bytes()); // high: 2 entries + code.extend_from_slice(&[0; 8]); + assert_eq!( + instruction_size(&code, pc), + Some(start - pc + 12 + 8), + "tableswitch pc={pc}" + ); + + let mut code = vec![0u8; pc]; + code.push(LOOKUPSWITCH); + code.resize(start, 0); + code.extend_from_slice(&[0; 4]); // default + code.extend_from_slice(&1i32.to_be_bytes()); // npairs + code.extend_from_slice(&[0; 8]); + assert_eq!( + instruction_size(&code, pc), + Some(start - pc + 8 + 8), + "lookupswitch pc={pc}" + ); + } + } + // instruction_size: tableswitch/lookupswitch with non-degenerate // low/high/npairs — existing tests only cover the all-zero case, which // can't distinguish `-` from `+` in the entry-count arithmetic. diff --git a/src/labels/clpi_audit.rs b/src/labels/clpi_audit.rs index adfb5ed7..016b1491 100644 --- a/src/labels/clpi_audit.rs +++ b/src/labels/clpi_audit.rs @@ -116,9 +116,9 @@ impl ClpiVsMplsAudit { pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit { // CLPI: the clip id is the file stem. let mut clpi_by_key: BTreeMap<(String, u16), (u8, String)> = BTreeMap::new(); - for (name, data) in read_dir_files(reader, udf, "/BDMV/CLIPINF", ".clpi") { + for_each_dir_file(reader, udf, "/BDMV/CLIPINF", ".clpi", |name, data| { let Ok(clip) = crate::clpi::parse(&data) else { - continue; + return; }; let clip_id = name[..name.len() - ".clpi".len()].to_string(); for s in clip.streams { @@ -129,14 +129,14 @@ pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit { .or_insert((s.coding_type, s.language)); } } - } + }); // MPLS: `streams` is the first PlayItem's STN_table, so it describes that clip. let mut mpls_by_key: BTreeMap<(String, u16), (u8, String)> = BTreeMap::new(); let mut mpls_clips: Vec = Vec::new(); - for (_, data) in read_dir_files(reader, udf, "/BDMV/PLAYLIST", ".mpls") { + for_each_dir_file(reader, udf, "/BDMV/PLAYLIST", ".mpls", |_, data| { let Ok(pl) = crate::mpls::parse(&data) else { - continue; + return; }; // A first clip is covered only if its STN kept a stream to compare. if let Some(pi) = pl.play_items.first() @@ -149,7 +149,7 @@ pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit { .or_insert_with(|| (s.coding_type, s.language.clone())); } } - } + }); // Merge views over auditable clips (those an MPLS STN describes). let covered: std::collections::BTreeSet<&str> = mpls_clips.iter().map(String::as_str).collect(); @@ -177,15 +177,17 @@ pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit { ClpiVsMplsAudit { rows } } -// (file name, bytes) of every readable `*` file directly under `dir`. -fn read_dir_files( +// Calls `f(file name, bytes)` for every readable `*` file directly under `dir`, reading +// and dropping one file at a time so a hostile directory can't pin N large files at once. +fn for_each_dir_file( reader: &mut dyn SectorSource, udf: &UdfFs, dir: &str, ext: &str, -) -> Vec<(String, Vec)> { + mut f: impl FnMut(&str, Vec), +) { let Some(d) = udf.find_dir(dir) else { - return Vec::new(); + return; }; let names: Vec = d .entries @@ -193,13 +195,11 @@ fn read_dir_files( .filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(ext)) .map(|e| e.name.clone()) .collect(); - names - .into_iter() - .filter_map(|name| { - let data = udf.read_file(reader, &format!("{dir}/{name}")).ok()?; - Some((name, data)) - }) - .collect() + for name in names { + if let Ok(data) = udf.read_file(reader, &format!("{dir}/{name}")) { + f(&name, data); + } + } } #[cfg(test)] diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index ee8539dc..a9e837ca 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -358,6 +358,55 @@ mod tests { ); } + // ── parse(): the playbackconfig.xml -> stream_map wiring ──────────────── + + // Lay /BDMV/JAR/00000/ onto an in-memory disc and open its filesystem. + fn jar_disc(files: &[(&str, &str)]) -> (crate::udf::fixture::MemDisc, UdfFs) { + use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir}; + let specs = files + .iter() + .enumerate() + .map(|(i, (name, body))| { + let n = i as u32; + file_with(name, 100 + n, 2000 + n * 4, body.as_bytes().to_vec(), true) + }) + .collect(); + let dir = |name: &str, icb, data, files, subdirs| DirSpec { + name: name.to_string(), + icb_lba: icb, + dir_data_lba: data, + files, + subdirs, + }; + let sub = dir("00000", 54, 55, specs, vec![]); + let jar = dir("JAR", 52, 53, vec![], vec![sub]); + let bdmv = dir("BDMV", 12, 13, vec![], vec![jar]); + let root = dir("", 10, 11, vec![], vec![bdmv]); + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + (disc, udf) + } + + // Dropping the playbackconfig read (or passing an empty map) would number the + // streams by fallback order and bind names to the wrong streams. + #[test] + fn parse_numbers_streams_from_playbackconfig() { + let sp = "a0ENG\ + a1FRA"; + let pc = "2a0\ + 1a1"; + let (mut disc, udf) = jar_disc(&[("streamproperties.xml", sp), ("playbackconfig.xml", pc)]); + let got = parse(&mut disc, &udf).expect("labels"); + let by_lang: Vec<(&str, u16)> = got + .labels + .iter() + .map(|l| (l.language.as_str(), l.stream_number)) + .collect(); + assert_eq!(by_lang, [("eng", 2), ("fra", 1)]); + } + // ── Additional hardening tests ───────────────────────────────────────── /// Spec: audio and subtitle counters are INDEPENDENT — audio fallback counter @@ -481,8 +530,7 @@ mod tests { /// Mutation: drop the skip loop → the fallback reuses a claimed number. #[test] fn fallback_skips_a_dense_block_of_claimed_numbers() { - // Force the counter past u16::MAX by pre-taking all values 1..=u16::MAX. - // Doing that for real would be slow; instead inject u16::MAX into taken. + // 500 claimed numbers force the fallback counter past a dense block. let mut map = HashMap::new(); for n in 1u16..=500 { map.insert(format!("taken_{}", n), n); @@ -509,8 +557,8 @@ mod tests { // This must not panic. let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted"); assert_eq!(nums.len(), 501); - // The last (unmapped) entry's number must be > 500 (skipped all taken). - assert!(nums[500] > 500); + // The unmapped entry skips every claimed number: exactly the next free one. + assert_eq!(nums[500], 501); } /// Spec: parse_stream_infos extracts COMMENTARY purpose from the Content element. diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index bc2ae6aa..98124711 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -7,7 +7,12 @@ use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab}; use crate::sector::SectorSource; use crate::udf::UdfFs; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; + +// Untrusted-input caps: labels per language_streams, distinct menu_base prefixes, +// and properties kept per prefix. +const MAX_CTRM_LABELS: usize = 4096; +const MAX_PROPS_PER_PREFIX: usize = 64; /// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or /// `language_streams.txt` inside a `/BDMV/JAR/*` archive. @@ -44,12 +49,16 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option fn merge(ls: Vec, mb: Vec) -> Vec { // language_streams has better type/purpose data, menu_base has button names - // Match by stream number + type, take name from menu_base + // Match by stream number + type, take name from menu_base (first match wins). + let mut mb_first: HashMap<(StreamLabelType, u16), &StreamLabel> = HashMap::new(); + for m in &mb { + mb_first + .entry((m.stream_type, m.stream_number)) + .or_insert(m); + } let mut result = ls; for label in &mut result { - if let Some(mb_match) = mb - .iter() - .find(|m| m.stream_type == label.stream_type && m.stream_number == label.stream_number) + if let Some(mb_match) = mb_first.get(&(label.stream_type, label.stream_number)) && label.name.is_empty() && !mb_match.name.is_empty() { @@ -59,11 +68,12 @@ fn merge(ls: Vec, mb: Vec) -> Vec { // Append menu_base-only streams (present in mb but not in ls by // (stream_type, stream_number)); language_streams is authoritative for // type/purpose but not necessarily a superset of menu_base. + let mut seen: HashSet<(StreamLabelType, u16)> = result + .iter() + .map(|l| (l.stream_type, l.stream_number)) + .collect(); for mb_label in mb { - let already = result.iter().any(|l| { - l.stream_type == mb_label.stream_type && l.stream_number == mb_label.stream_number - }); - if !already { + if seen.insert((mb_label.stream_type, mb_label.stream_number)) { result.push(mb_label); } } @@ -98,6 +108,9 @@ fn parse_language_streams_text(text: &str) -> Vec { let mut labels = Vec::new(); for line in text.lines() { + if labels.len() >= MAX_CTRM_LABELS { + break; + } let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; @@ -227,6 +240,59 @@ mod tests { parse_menu_base_text(text) } + #[test] + fn menu_base_tie_order_is_deterministic_by_prefix() { + // Inserted in reverse; many prefixes so hash order can't match by luck. + let mut text = String::new(); + for i in (0..64).rev() { + text.push_str(&format!( + "p{i:02}.class=AudioButton\np{i:02}.streamNumber=2\np{i:02}.name=N{i:02}\n" + )); + } + let names: Vec = parse_props(&text).into_iter().map(|l| l.name).collect(); + let want: Vec = (0..64).map(|i| format!("N{i:02}")).collect(); + assert_eq!(names, want); + } + + #[test] + fn menu_base_spaces_around_separator_are_accepted() { + let labels = parse_props("audio_1.streamNumber = 3\naudio_1.name = Foo\n"); + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].stream_number, 3); + assert_eq!(labels[0].name, "Foo"); + } + + #[test] + fn parsers_cap_retained_entries() { + let mut mb = String::new(); + let mut ls = String::new(); + for i in 0..(MAX_CTRM_LABELS + 100) { + mb.push_str(&format!("audio_{i}.streamNumber=1\n")); + ls.push_str("x,audio_production,1,eng\n"); + } + assert!(parse_menu_base_text(&mb).len() <= MAX_CTRM_LABELS); + assert_eq!(parse_language_streams_text(&ls).len(), MAX_CTRM_LABELS); + } + + #[test] + fn merge_fills_names_and_appends_only_missing() { + let mk = |n: u16, name: &str| StreamLabel { + stream_id: None, + stream_number: n, + stream_type: StreamLabelType::Audio, + language: String::new(), + name: name.into(), + purpose: LabelPurpose::Normal, + qualifier: LabelQualifier::None, + codec_hint: String::new(), + variant: String::new(), + }; + let out = merge(vec![mk(1, "")], vec![mk(1, "A"), mk(1, "B"), mk(2, "C")]); + assert_eq!(out.len(), 2); + assert_eq!(out[0].name, "A"); + assert_eq!(out[1].name, "C"); + } + #[test] fn commentary_via_name() { let labels = parse_props( @@ -762,7 +828,8 @@ fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option Vec { // Parse key=value, group by prefix - let mut entries: HashMap> = HashMap::new(); + // BTreeMap: deterministic prefix order so equal (type, number) ties sort stably. + let mut entries: BTreeMap> = BTreeMap::new(); for line in text.lines() { let line = line.trim(); @@ -773,16 +840,19 @@ fn parse_menu_base_text(text: &str) -> Vec { Some(p) => p, None => continue, }; - let full_key = &line[..eq_pos]; - let value = &line[eq_pos + 1..]; + let full_key = line[..eq_pos].trim(); + let value = line[eq_pos + 1..].trim_start(); if let Some(dot_pos) = full_key.rfind('.') { let prefix = full_key[..dot_pos].to_string(); let key = full_key[dot_pos + 1..].to_string(); - entries - .entry(prefix) - .or_default() - .insert(key, value.to_string()); + if !entries.contains_key(&prefix) && entries.len() >= MAX_CTRM_LABELS { + continue; + } + let props = entries.entry(prefix).or_default(); + if props.len() < MAX_PROPS_PER_PREFIX || props.contains_key(&key) { + props.insert(key, value.to_string()); + } } } diff --git a/src/labels/dbp.rs b/src/labels/dbp.rs index 3e0cd732..b41bcd1c 100644 --- a/src/labels/dbp.rs +++ b/src/labels/dbp.rs @@ -26,21 +26,20 @@ use std::collections::BTreeMap; /// only dbp discs instead of firing on every BD-J disc. `parse()` repeats the /// check as belt-and-suspenders. pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { - jar::for_each_jar(reader, udf, |_entry, archive| { - jar::has_path_prefix(archive, "com/dbp/").then_some(()) - }) - .is_some() + jar::any_jar_has_prefix(reader, udf, "com/dbp/") } /// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and /// extract its stream labels. Returns `None` if no jar carries a /// `com/dbp/` package path or none yields any labels. pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { + // One inflate budget for every jar this parse sweeps. + let mut budget = jar::PARSE_INFLATE_BUDGET; jar::for_each_jar(reader, udf, |_entry_name, archive| { if !jar::has_path_prefix(archive, "com/dbp/") { return None; } - let labels = scan_jar(archive); + let labels = scan_jar(archive, &mut budget); if labels.is_empty() { None } else { @@ -51,14 +50,14 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option }) } -fn scan_jar(archive: &mut jar::Jar) -> Vec { +fn scan_jar(archive: &mut jar::Jar, budget: &mut u64) -> Vec { // BTreeMap keeps the last-written label per stream slot deterministic. // The same TextField,Audio1,... string can appear in multiple classes // (button-state variants, fallbacks); last write should agree, but wins defensively. let mut audios: BTreeMap = BTreeMap::new(); let mut subs: BTreeMap = BTreeMap::new(); - jar::for_each_class(archive, |_class_name, class| { + jar::for_each_class_budgeted(archive, budget, |_class_name, class| { for (_idx, cp) in class.constant_pool.iter() { if let CpInfo::Utf8(s) = cp { collect_textfield(s, &mut audios, &mut subs); @@ -115,7 +114,10 @@ fn collect_textfield( return; } if let Some(rest) = kind_n.strip_prefix("Audio") { - if let Ok(n) = rest.parse::() { + // Stream numbers are 1-based; 0 (NO_STN_SLOT) can never bind. + if let Ok(n) = rest.parse::() + && n > 0 + { retain_label(audios, n, label); } } else if let Some(rest) = kind_n.strip_prefix("Subtitle") @@ -152,6 +154,11 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa mod tests { use super::super::{LabelPurpose, LabelQualifier}; use super::*; + + // A budget no test fixture can exhaust. + fn unbounded() -> u64 { + u64::MAX + } use std::io::{Cursor, Write as _}; // Minimal, structurally valid `.class` file (JVMS §4.1) with only the @@ -181,7 +188,7 @@ mod tests { /// Zip `entries` (name -> bytes) into an in-memory, Stored (uncompressed) /// `jar::Jar` via the `zip` crate's own writer — a real archive, not a /// hand-rolled central directory. - fn build_jar(entries: &[(&str, Vec)]) -> jar::Jar { + fn build_jar_bytes(entries: &[(&str, Vec)]) -> Vec { let mut buf = Vec::new(); { let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf)); @@ -193,10 +200,63 @@ mod tests { } writer.finish().expect("finish zip"); } - zip::ZipArchive::new(Cursor::new(buf)).expect("valid zip") + buf } - // Wires for_each_class + collect_textfield + make_label into the real + fn build_jar(entries: &[(&str, Vec)]) -> jar::Jar { + zip::ZipArchive::new(Cursor::new(build_jar_bytes(entries))).expect("valid zip") + } + + // An in-memory disc holding one `/BDMV/JAR/` jar per entry. + fn jar_disc(jars: &[(&str, Vec)]) -> (crate::udf::fixture::MemDisc, UdfFs) { + use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir}; + let specs = jars + .iter() + .enumerate() + .map(|(i, (name, bytes))| { + let n = i as u32; + file_with(name, 100 + n, 2000 + n * 4, bytes.clone(), true) + }) + .collect(); + let dir = |name: &str, icb, data, files, subdirs| DirSpec { + name: name.to_string(), + icb_lba: icb, + dir_data_lba: data, + files, + subdirs, + }; + let jar = dir("JAR", 52, 53, specs, vec![]); + let bdmv = dir("BDMV", 12, 13, vec![], vec![jar]); + let root = dir("", 10, 11, vec![], vec![bdmv]); + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + (disc, udf) + } + + // The com/dbp/ prefix is the only thing keeping this parser (ahead of + // deluxe in PARSERS) off other BD-J discs that carry a TextField string. + #[test] + fn detect_and_parse_require_the_dbp_package_prefix() { + let class = + || build_class(&["LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763"]); + let (mut other, udf) = jar_disc(&[( + "00000.jar", + build_jar_bytes(&[("com/other/Menu.class", class())]), + )]); + assert!(!detect(&mut other, &udf)); + assert!(parse(&mut other, &udf).is_none()); + + let (mut dbp, udf) = jar_disc(&[( + "00000.jar", + build_jar_bytes(&[("com/dbp/Menu.class", class())]), + )]); + assert!(detect(&mut dbp, &udf)); + assert_eq!(parse(&mut dbp, &udf).expect("labels").labels.len(), 1); + } + + // Wires the class sweep + collect_textfield + make_label into the real // per-jar scan (unit tests above only cover the pure pieces). Mutation // pin: catches scan_jar's body being replaced with `vec![]`. #[test] @@ -209,7 +269,7 @@ mod tests { ]); let mut archive = build_jar(&[("com/dbp/Menu.class", class_bytes)]); - let labels = scan_jar(&mut archive); + let labels = scan_jar(&mut archive, &mut unbounded()); assert_eq!( labels.len(), @@ -231,6 +291,21 @@ mod tests { assert_eq!(sub.qualifier, LabelQualifier::Sdh); } + // One inflate budget covers every jar a parse sweeps: what the first jar + // spends is gone for the second. + #[test] + fn scan_jar_budget_is_shared_across_jars() { + let class = build_class(&["LTextField,Audio1,English,Fontstrip_Composite,296,763"]); + let len = class.len() as u64; + let mut first = build_jar(&[("com/dbp/A.class", class.clone())]); + let mut second = build_jar(&[("com/dbp/B.class", class)]); + let mut budget = len + len / 2; + assert_eq!(scan_jar(&mut first, &mut budget).len(), 1); + assert_eq!(budget, len / 2); + assert!(scan_jar(&mut second, &mut budget).is_empty()); + assert_eq!(budget, 0); + } + // Immunity pin: stream numbers come from the AudioN/SubtitleN token, not // iteration order, so gaps/skipped entries don't shift later labels. // Mutation: numbering by iteration order would rebind Audio4/Subtitle3. @@ -248,7 +323,7 @@ mod tests { ]); let mut archive = build_jar(&[("com/dbp/Menu.class", class_bytes)]); - let labels = scan_jar(&mut archive); + let labels = scan_jar(&mut archive, &mut unbounded()); let nums: Vec<(StreamLabelType, u16)> = labels .iter() .map(|l| (l.stream_type, l.stream_number)) @@ -465,4 +540,13 @@ mod tests { assert_eq!(l.language, "eng"); assert_eq!(l.qualifier, LabelQualifier::DescriptiveService); } + + #[test] + fn audio_zero_is_not_retained() { + let (mut a, mut s) = (BTreeMap::new(), BTreeMap::new()); + collect_textfield("xTextField,Audio0,English,y", &mut a, &mut s); + assert!(a.is_empty(), "Audio0 is NO_STN_SLOT and can never bind"); + collect_textfield("xTextField,Audio1,English,y", &mut a, &mut s); + assert_eq!(a.len(), 1); + } } diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index d313b342..b0a0715b 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -7,9 +7,16 @@ //! matches each enum's `` shape rather than class names. use super::class_reader::{ - AASTORE, ANEWARRAY, BIPUSH, ClassFile, CodeAttribute, ConstantPool, CpInfo, GETSTATIC, - ICONST_0, ICONST_1, ICONST_2, ICONST_3, ICONST_4, ICONST_5, ICONST_M1, INVOKESPECIAL, LDC, - LDC_W, NEW, NEWARRAY, PUTSTATIC, SIPUSH, + ACONST_NULL, ALOAD, ALOAD_3, ANEWARRAY, ARRAYLENGTH, ASTORE, ASTORE_3, ATHROW, BIPUSH, + CHECKCAST, ClassFile, CodeAttribute, ConstantPool, CpInfo, D2L, DALOAD, DCMPG, DCONST_0, + DCONST_1, DLOAD, DLOAD_0, DLOAD_3, DNEG, DREM, DUP, DUP_X1, DUP_X2, DUP2, DUP2_X1, DUP2_X2, + F2D, F2L, FLOAD, GETFIELD, GETSTATIC, GOTO, GOTO_W, I2D, I2L, I2S, IADD, IALOAD, IASTORE, + ICONST_0, ICONST_1, ICONST_2, ICONST_3, ICONST_4, ICONST_5, ICONST_M1, IF_ACMPNE, IF_ICMPEQ, + IFEQ, IFLE, IFNONNULL, IFNULL, IINC, ILOAD, INEG, INSTANCEOF, INVOKEINTERFACE, INVOKESPECIAL, + INVOKESTATIC, INVOKEVIRTUAL, IRETURN, ISHL, ISTORE, Instruction, L2D, LALOAD, LCMP, LCONST_0, + LCONST_1, LDC, LDC_W, LDC2_W, LLOAD, LLOAD_0, LLOAD_3, LOOKUPSWITCH, LXOR, MONITORENTER, + MONITOREXIT, MULTIANEWARRAY, NEW, NEWARRAY, NOP, POP, POP2, PUTFIELD, PUTSTATIC, RETURN, + SALOAD, SASTORE, SIPUSH, SWAP, TABLESWITCH, WIDE, }; use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, jar, vocab}; use crate::sector::SectorSource; @@ -20,10 +27,7 @@ pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { // Real signal is `com/bydeluxe/` in a jar's central directory: a cheap // scan, no bytecode walk, so this parser claims only Deluxe discs. // `parse()` repeats the check. - jar::for_each_jar(reader, udf, |_entry, archive| { - jar::has_path_prefix(archive, "com/bydeluxe/").then_some(()) - }) - .is_some() + jar::any_jar_has_prefix(reader, udf, "com/bydeluxe/") } pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { @@ -31,6 +35,8 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option // studios, each shipping `/BDMV/JAR//config.xml` naming the studio. // Matching below is studio-agnostic, so this is informational only (logged). let studio = detect_studio(reader, udf); + // One inflate budget for every sweep over every jar this parse opens. + let mut budget = jar::PARSE_INFLATE_BUDGET; jar::for_each_jar(reader, udf, |entry_name, archive| { if !jar::has_path_prefix(archive, "com/bydeluxe/") { @@ -38,7 +44,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option } // Phase A — master enums (Language / Purpose / VideoFormat / Region / Studio). - let enums = identify_master_enums(archive); + let enums = identify_master_enums(archive, &mut budget); if enums.is_empty() { tracing::info!( jar = ?entry_name, @@ -62,7 +68,8 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option // Phase C — find ALL binding-class candidates (audio + subtitle often // split across two classes on Deluxe). Each gets its own `` // walk; constructions union into a single stream list. - let binding_classes = find_binding_classes(archive, &master_table.class_name_set()); + let binding_classes = + find_binding_classes(archive, &master_table.class_name_set(), &mut budget); if binding_classes.is_empty() { tracing::info!( jar = ?entry_name, @@ -81,15 +88,17 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option // Phase D — decode each binding class's . let mut streams: Vec = Vec::new(); + let mut drift = 0usize; for (name, _) in &binding_classes { // Cross-class union is bounded by the same cap as each walk. let room = MAX_CONSTRUCTIONS.saturating_sub(streams.len()); if room == 0 { break; } - let mut decoded = decode_binding(archive, name, &master_table); - decoded.truncate(room); - streams.extend(decoded); + let mut decoded = decode_binding(archive, name, &master_table, &mut budget); + drift = drift.saturating_add(decoded.drift); + decoded.constructions.truncate(room); + streams.extend(decoded.constructions); } if streams.is_empty() { tracing::info!( @@ -110,9 +119,17 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option subtitle = labels.iter().filter(|l| l.stream_type == StreamLabelType::Subtitle).count(), "deluxe emitted labels", ); - // High confidence: master enums matched, binding class decoded, and at - // least one binding resolved to a real (language, purpose, codec) tuple. - // Un-named STN slots are back-filled from the MPLS floor by the registry. + // High only when the symbolic walk stayed in sync and no sweep was cut + // short: drift can bind a label to the wrong STN slot. + if drift > 0 || budget == 0 { + tracing::warn!( + jar = ?entry_name, + drift, + budget_spent = budget == 0, + "deluxe: bytecode decode was not exact, labels downgraded to low confidence" + ); + return Some(ParseResult::low(labels)); + } Some(ParseResult::high(labels)) }) } @@ -288,6 +305,8 @@ struct CandidatePool { /// resolve a different master enum on a second run and emit different /// labels for unchanged input. by_class: BTreeMap>, + /// `putstatic` field names of fingerprint-matching classes (Phase D's ordinals). + fields: HashMap>, /// Retained bytes: class names plus every retained string. bytes: usize, } @@ -308,17 +327,32 @@ impl CandidatePool { self.by_class.insert(class_name.to_string(), ldcs); true } + + /// Retain `fields` for an already-inserted class, within the byte budget. + /// Returns false when they were rejected (pool full). + fn insert_fields(&mut self, class_name: &str, fields: Vec) -> bool { + let cost = fields.iter().map(String::len).sum::(); + if self.bytes.saturating_add(cost) > MAX_CANDIDATE_TOTAL_BYTES { + return false; + } + self.bytes += cost; + self.fields.insert(class_name.to_string(), fields); + true + } } /// Phase A. Walk every `.class` in `archive`, identify the master /// enums by `` ldc-sequence fingerprint. Returns a vector of /// `(label, MasterEnum)` — at most one match per fingerprint label. -fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnum)> { +fn identify_master_enums( + archive: &mut jar::Jar, + budget: &mut u64, +) -> Vec<(&'static str, MasterEnum)> { // First pass: collect every class's ldc strings, keyed by the JVM // INTERNAL name (`this_class`), not the zip entry name — binding classes // reference enum constants as `getstatic .f`. let mut pool = CandidatePool::default(); - jar::for_each_class(archive, |zip_name, class| { + jar::for_each_class_budgeted(archive, budget, |zip_name, class| { let Some(ldcs) = clinit_ldc_strings(class) else { return; }; @@ -326,7 +360,16 @@ fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnu return; } let key = class.this_class_name().unwrap_or(zip_name); - if !pool.insert(key, ldcs) { + let matches_fp = FINGERPRINTS.iter().any(|fp| fp_matches(fp, &ldcs)); + if pool.insert(key, ldcs) { + if matches_fp && !pool.insert_fields(key, clinit_enum_field_names(class)) { + tracing::debug!( + class = ?key, + bytes = pool.bytes, + "deluxe: candidate pool cap hit, enum field names dropped" + ); + } + } else { tracing::debug!( class = ?key, classes = pool.by_class.len(), @@ -336,19 +379,17 @@ fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnu } }); let candidates = pool.by_class; + let mut fields_by_class = pool.fields; // Second pass: match each fingerprint against the candidate pool. let mut out = Vec::new(); for fp in FINGERPRINTS { let mut best: Option<(String, Vec)> = None; for (name, ldcs) in &candidates { - if !ldcs_match_prefix(ldcs, fp.prefix) { + if !fp_matches(fp, ldcs) { continue; } let count = ldcs.len(); - if count.abs_diff(fp.expected_count) > fp.count_tolerance { - continue; - } // Prefer exact-count match; otherwise first hit wins. match &best { None => best = Some((name.clone(), ldcs.clone())), @@ -367,18 +408,6 @@ fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnu return Vec::new(); } - // Second pass: capture obfuscated `putstatic` field names for matched - // classes so Phase D can resolve `getstatic .` to an ordinal. - // Targeted re-walk of only the matched classes, not the whole jar again. - let want: HashSet<&str> = out.iter().map(|(_, name, _)| name.as_str()).collect(); - let mut fields_by_class: HashMap> = HashMap::new(); - jar::for_each_class(archive, |zip_name, class| { - let iname = class.this_class_name().unwrap_or(zip_name); - if want.contains(iname) && !fields_by_class.contains_key(iname) { - fields_by_class.insert(iname.to_string(), clinit_enum_field_names(class)); - } - }); - out.into_iter() .map(|(label, class_name, values)| { let fields = fields_by_class.remove(&class_name).unwrap_or_default(); @@ -489,6 +518,12 @@ fn clinit_ldc_strings(class: &super::class_reader::ClassFile) -> Option` ldc strings fit a fingerprint (prefix and count window). +fn fp_matches(fp: &Fingerprint, ldcs: &[String]) -> bool { + ldcs_match_prefix(ldcs, fp.prefix) + && ldcs.len().abs_diff(fp.expected_count) <= fp.count_tolerance +} + /// True if the first `prefix.len()` entries of `ldcs` match `prefix` /// exactly. Case-sensitive (enum names are stable strings, not free /// text). @@ -508,10 +543,11 @@ fn ldcs_match_prefix(ldcs: &[String], prefix: &[&str]) -> bool { fn find_binding_classes( archive: &mut jar::Jar, master_enum_classes: &HashSet<&str>, + budget: &mut u64, ) -> Vec<(String, usize)> { const MIN_GETSTATIC: usize = 4; let mut candidates: Vec<(String, usize)> = Vec::new(); - jar::for_each_class(archive, |class_name, class| { + jar::for_each_class_budgeted(archive, budget, |class_name, class| { let count = count_master_enum_getstatic(class, master_enum_classes); if count >= MIN_GETSTATIC { candidates.push((class_name.to_string(), count)); @@ -590,6 +626,24 @@ pub(crate) enum StackVal { /// opaque. Lets the walker stay in sync past loads/computed /// values it doesn't understand. Unknown, + /// An opaque long or double: one value, two stack words (JVMS §2.11.1). + Wide, +} + +impl StackVal { + // Stack words this value occupies. + fn words(&self) -> usize { + if matches!(self, StackVal::Wide) { 2 } else { 1 } + } + + // Opaque value of a field descriptor type. + fn of_type(descriptor: &str) -> Self { + if matches!(descriptor, "J" | "D") { + StackVal::Wide + } else { + StackVal::Unknown + } + } } /// Fully-qualified class name of the BD-J spec codec enum that @@ -600,19 +654,28 @@ const BD_CODING_TYPE_CLASS: &str = "org/bluray/ti/CodingType"; // decode_binding_class's per-class union and parse's cross-class union). const MAX_CONSTRUCTIONS: usize = 4096; -/// Phase D entry point: find the binding class in `archive`, run the -/// bytecode walker against its ``, return one `Construction` -/// per `new X / invokespecial X.` sequence. +/// Result of walking a binding class's ``. +#[derive(Debug, Default)] +pub(crate) struct Decoded { + /// One per `new X / invokespecial X.` sequence, in bytecode order. + pub constructions: Vec, + /// Events where the symbolic stack lost sync with the real one. + pub drift: usize, +} + +/// Phase D entry point: find the binding class in `archive` and run the +/// bytecode walker against its ``. fn decode_binding( archive: &mut jar::Jar, binding_class_name: &str, master: &MasterEnumTable, -) -> Vec { + budget: &mut u64, +) -> Decoded { let target_name = binding_class_name.to_string(); - // Short-circuit on the name match: try_each_class stops iterating (and + // Short-circuit on the name match: the sweep stops iterating (and // decompressing/parsing remaining .class entries) once the closure // returns Some, instead of walking the whole jar past the target. - jar::try_each_class(archive, |class_name, class| { + jar::try_each_class_budgeted(archive, budget, |class_name, class| { if class_name != target_name { return None; } @@ -622,13 +685,9 @@ fn decode_binding( } /// Walk every method named `` (typically only one) on this -/// class with the symbolic stack machine. Returns each construction -/// emitted. -pub(crate) fn decode_binding_class( - class: &ClassFile, - master: &MasterEnumTable, -) -> Vec { - let mut all: Vec = Vec::new(); +/// class with the symbolic stack machine. +pub(crate) fn decode_binding_class(class: &ClassFile, master: &MasterEnumTable) -> Decoded { + let mut all = Decoded::default(); for m in &class.methods { if class.member_name(m) != Some("") { continue; @@ -638,15 +697,16 @@ pub(crate) fn decode_binding_class( }; let mut ctx = BindingDecoder::new(&class.constant_pool, master); ctx.run(&code); + all.drift = all.drift.saturating_add(ctx.drift); // Bound the union too: JVMS §4.6 makes (name, descriptor) unique per // class so a real class has one ``, but this reader does not // enforce that and a crafted class can repeat it. - let room = MAX_CONSTRUCTIONS.saturating_sub(all.len()); + let room = MAX_CONSTRUCTIONS.saturating_sub(all.constructions.len()); if room == 0 { break; } ctx.constructions.truncate(room); - all.extend(ctx.constructions); + all.constructions.extend(ctx.constructions); } all } @@ -661,6 +721,8 @@ struct BindingDecoder<'a> { /// in [`run`](Self::run). Zero until then. max_stack: usize, constructions: Vec, + /// Count of events where the symbolic stack lost sync (see [`Decoded::drift`]). + drift: usize, } impl<'a> BindingDecoder<'a> { @@ -671,6 +733,7 @@ impl<'a> BindingDecoder<'a> { stack: Vec::new(), max_stack: 0, constructions: Vec::new(), + drift: 0, } } @@ -678,9 +741,37 @@ impl<'a> BindingDecoder<'a> { /// `constructions` field holds the result. pub(crate) fn run(&mut self, code: &CodeAttribute<'_>) { self.max_stack = code.max_stack as usize; + let mut end = 0; for insn in code.instructions() { + end = insn.pc + 1 + insn.operands.len(); self.step(insn); } + // The iterator stops at an opcode it cannot size: the rest is never decoded. + if end < code.code.len() { + self.drift = self.drift.saturating_add(1); + tracing::debug!(pc = end, "deluxe: undecodable opcode ends the walk early"); + } + } + + // The symbolic stack no longer matches the real one at `insn`. + fn lost_sync(&mut self, insn: &Instruction<'_>) { + self.drift = self.drift.saturating_add(1); + tracing::debug!( + pc = insn.pc, + opcode = insn.opcode, + "deluxe: symbolic stack drift" + ); + } + + // A `` of `class` whose operands were lost: record an argless construction so the + // binding still takes its STN slot (numbering stays aligned); it resolves no label. + fn placeholder(&mut self, class: &str, is_container: bool) { + if !is_container && self.constructions.len() < MAX_CONSTRUCTIONS { + self.constructions.push(Construction { + binding_type: class.to_string(), + args: Vec::new(), + }); + } } // Pushes onto the symbolic stack, honouring the declared max_stack: JVMS 4.7.3 forbids @@ -688,12 +779,64 @@ impl<'a> BindingDecoder<'a> { // crafted class. fn push(&mut self, val: StackVal) { if self.stack.len() >= self.max_stack { + self.drift = self.drift.saturating_add(1); return; } self.stack.push(val); } - fn step(&mut self, insn: super::class_reader::Instruction<'_>) { + // Pops `n` values of any category; on underflow resyncs on an empty stack. + fn pop_values(&mut self, insn: &Instruction<'_>, n: usize) { + match self.stack.len().checked_sub(n) { + Some(keep) => self.stack.truncate(keep), + None => { + self.lost_sync(insn); + self.stack.clear(); + } + } + } + + // Pops whole values totalling exactly `n` stack words (top last). None, after a + // resync, on underflow or when that would split a long/double (JVMS §2.11.1). + fn take_words(&mut self, insn: &Instruction<'_>, n: usize) -> Option> { + let (mut at, mut got) = (self.stack.len(), 0); + while got < n && at > 0 { + at -= 1; + got += self.stack[at].words(); + } + if got != n { + self.lost_sync(insn); + self.stack.clear(); + return None; + } + Some(self.stack.split_off(at)) + } + + // dup, dup_x1/x2, dup2, dup2_x1/x2: copy the top `n` words beneath the next `m`. + fn dup_under(&mut self, insn: &Instruction<'_>, n: usize, m: usize) { + let Some(top) = self.take_words(insn, n) else { + return; + }; + let Some(under) = self.take_words(insn, m) else { + return; + }; + for v in top.clone().into_iter().chain(under).chain(top) { + self.push(v); + } + } + + // Pops `n` operands and pushes one result, a long/double when `wide`. + fn op(&mut self, insn: &Instruction<'_>, n: usize, wide: bool) { + self.pop_values(insn, n); + self.push(if wide { + StackVal::Wide + } else { + StackVal::Unknown + }); + } + + fn step(&mut self, insn: Instruction<'_>) { + let insn = &insn; match insn.opcode { // Push small int constants. ICONST_M1 => self.push(StackVal::Int(-1)), @@ -717,6 +860,9 @@ impl<'a> BindingDecoder<'a> { self.push(StackVal::Unknown); } } + LCONST_0 | LCONST_1 | DCONST_0 | DCONST_1 | LDC2_W => self.push(StackVal::Wide), + // aconst_null, fconst_* (the int/long/double constants are taken above). + ACONST_NULL..=DCONST_1 => self.push(StackVal::Unknown), // ldc/ldc_w: push Int when the operand is an Integer // constant; otherwise push Unknown (we don't care about // Strings here — labels come via getstatic, not ldc). @@ -730,6 +876,21 @@ impl<'a> BindingDecoder<'a> { .unwrap_or(StackVal::Unknown); self.push(v); } + // Local loads and stores (the long/double loads first). + LLOAD | DLOAD | LLOAD_0..=LLOAD_3 | DLOAD_0..=DLOAD_3 => self.push(StackVal::Wide), + ILOAD..=ALOAD_3 => self.push(StackVal::Unknown), + ISTORE..=ASTORE_3 => self.pop_values(insn, 1), + WIDE => match insn.operand_u8() { + Some(LLOAD | DLOAD) => self.push(StackVal::Wide), + Some(ILOAD | FLOAD | ALOAD) => self.push(StackVal::Unknown), + Some(ISTORE..=ASTORE) => self.pop_values(insn, 1), + Some(IINC) => {} + _ => self.lost_sync(insn), + }, + // Array loads pop (arrayref, index); stores also pop the value. + LALOAD | DALOAD => self.op(insn, 2, true), + IALOAD..=SALOAD => self.op(insn, 2, false), + IASTORE..=SASTORE => self.pop_values(insn, 3), // new X — push an uninit-object marker. The matching // invokespecial will consume this + the args and emit a // Construction. @@ -741,137 +902,155 @@ impl<'a> BindingDecoder<'a> { .to_string(); self.push(StackVal::NewObj(class_name)); } - // dup — duplicate top of stack. - 0x59 /* dup */ => { - if let Some(top) = self.stack.last().cloned() { - self.push(top); + // Word-level stack ops (a long/double is one two-word value). + POP => drop(self.take_words(insn, 1)), + POP2 => drop(self.take_words(insn, 2)), + DUP => self.dup_under(insn, 1, 0), + DUP_X1 => self.dup_under(insn, 1, 1), + DUP_X2 => self.dup_under(insn, 1, 2), + DUP2 => self.dup_under(insn, 2, 0), + DUP2_X1 => self.dup_under(insn, 2, 1), + DUP2_X2 => self.dup_under(insn, 2, 2), + SWAP => { + if let Some(a) = self.take_words(insn, 1) + && let Some(b) = self.take_words(insn, 1) + { + for v in a.into_iter().chain(b) { + self.push(v); + } } } + // Arithmetic: in 0x60..=0x83 the long/double forms are the odd opcodes. + IADD..=DREM | ISHL..=LXOR => self.op(insn, 2, insn.opcode % 2 == 1), + INEG..=DNEG => self.op(insn, 1, insn.opcode % 2 == 1), + IINC => {} + I2L | I2D | L2D | F2L | F2D | D2L => self.op(insn, 1, true), + I2L..=I2S => self.op(insn, 1, false), + LCMP..=DCMPG => self.op(insn, 2, false), + // Conditional branches pop their operands; the walk stays linear. + IFEQ..=IFLE | IFNULL | IFNONNULL | TABLESWITCH | LOOKUPSWITCH => { + self.pop_values(insn, 1) + } + IF_ICMPEQ..=IF_ACMPNE => self.pop_values(insn, 2), + MONITORENTER | MONITOREXIT => self.pop_values(insn, 1), // getstatic Y.Z — if Y is one of our master enum classes, // resolve Z to an ordinal and push an EnumRef. Otherwise - // push Unknown so we stay in sync. + // push an opaque value so we stay in sync. GETSTATIC => { - let val = insn - .cp_index() - .and_then(|i| self.pool.member_ref(i)) - .map(|m| { - // Resolution: CodingType.X → CodingType(X); master-enum - // classname.X → EnumRef(kind, ord); else Unknown. - if m.class_name == BD_CODING_TYPE_CLASS { - StackVal::CodingType(m.name.to_string()) - } else if let Some((kind, ord)) = self.master.resolve(m.class_name, m.name) - { - StackVal::EnumRef { kind, ordinal: ord } - } else { - StackVal::Unknown - } - }) - .unwrap_or(StackVal::Unknown); + let Some(m) = insn.cp_index().and_then(|i| self.pool.member_ref(i)) else { + self.lost_sync(insn); + self.push(StackVal::Unknown); + return; + }; + let val = if m.class_name == BD_CODING_TYPE_CLASS { + StackVal::CodingType(m.name.to_string()) + } else if let Some((kind, ordinal)) = self.master.resolve(m.class_name, m.name) { + StackVal::EnumRef { kind, ordinal } + } else { + StackVal::of_type(m.descriptor) + }; self.push(val); } + PUTSTATIC => self.pop_values(insn, 1), + GETFIELD => { + let Some(m) = insn.cp_index().and_then(|i| self.pool.member_ref(i)) else { + self.lost_sync(insn); + return self.op(insn, 1, false); + }; + self.pop_values(insn, 1); + self.push(StackVal::of_type(m.descriptor)); + } + PUTFIELD => self.pop_values(insn, 2), // invokespecial X.(...) — pop args per descriptor. If the // object underneath the args is a NewObj of class X (set by an // earlier `new X / dup`), emit a Construction. INVOKESPECIAL => { - let Some(idx) = insn.cp_index() else { return }; - let Some(member) = self.pool.member_ref(idx) else { return }; + let Some(member) = insn.cp_index().and_then(|i| self.pool.member_ref(i)) else { + self.lost_sync(insn); + return; + }; let arg_count = parse_method_arg_count(member.descriptor); + let is_init = member.name == ""; // A per-stream binding ctor takes only scalars/enum refs, never an // array; a ctor with an array param is a container/title wrapper and // must not be recorded as a stream binding (stack still unwinds below). let is_container = member.descriptor.contains('['); if self.stack.len() < arg_count + 1 { - // Stack-machine drift: bail on this construction without - // panicking; the walker tolerates malformed input best-effort. + // Underflow: resync on an empty stack, keep the binding's slot. + self.lost_sync(insn); self.stack.clear(); + if is_init { + self.placeholder(member.class_name, is_container); + } return; } - let args: Vec = self - .stack - .split_off(self.stack.len() - arg_count); + let args: Vec = self.stack.split_off(self.stack.len() - arg_count); // Underneath the args: the object the constructor // operates on. For our pattern it's NewObj(X). - let receiver = self.stack.pop().unwrap_or(StackVal::Unknown); - if let StackVal::NewObj(name) = receiver - && name == member.class_name - && !is_container { + match self.stack.pop() { + Some(StackVal::NewObj(name)) if is_init && name == member.class_name => { // Bounded by MAX_CONSTRUCTIONS: an unbounded push here // is ~1 GiB reachable from a crafted ``. - if self.constructions.len() >= MAX_CONSTRUCTIONS { - return; + if !is_container && self.constructions.len() < MAX_CONSTRUCTIONS { + self.constructions.push(Construction { + binding_type: name, + args, + }); } - self.constructions.push(Construction { - binding_type: name, - args, - }); } + // `` has no `this`: an `` receiver that is not the + // matching `new` means the args popped are not this call's. + _ if is_init => { + self.lost_sync(insn); + self.placeholder(member.class_name, is_container); + } + _ => self.push_return(member.descriptor), + } } // invokevirtual / invokestatic / invokeinterface — pop - // args per descriptor, push a return placeholder unless - // descriptor returns V (void). - 0xB6 /* invokevirtual */ | 0xB8 /* invokestatic */ | 0xB9 /* invokeinterface */ => { - let Some(idx) = insn.cp_index() else { return }; - let Some(member) = self.pool.member_ref(idx) else { return }; - let arg_count = parse_method_arg_count(member.descriptor); - let extra = if insn.opcode == 0xB6 || insn.opcode == 0xB9 { 1 } else { 0 }; - let to_pop = arg_count + extra; - if self.stack.len() < to_pop { - self.stack.clear(); - } else { - self.stack.truncate(self.stack.len() - to_pop); - } - // Push return placeholder unless void. - if !member.descriptor.ends_with(")V") { - self.push(StackVal::Unknown); + // args per descriptor (plus the receiver), push the return value. + INVOKEVIRTUAL | INVOKESTATIC | INVOKEINTERFACE => { + let Some(member) = insn.cp_index().and_then(|i| self.pool.member_ref(i)) else { + self.lost_sync(insn); + return; + }; + let receiver = usize::from(insn.opcode != INVOKESTATIC); + self.pop_values(insn, parse_method_arg_count(member.descriptor) + receiver); + self.push_return(member.descriptor); + } + // anewarray / newarray / arraylength / instanceof: pop 1, push a ref or int. + ANEWARRAY | NEWARRAY | ARRAYLENGTH | INSTANCEOF => self.op(insn, 1, false), + MULTIANEWARRAY => { + let dims = insn.operands.get(2).copied().unwrap_or(0); + self.op(insn, dims as usize, false); + } + // checkcast leaves the (same) reference on the stack. + CHECKCAST => { + if self.stack.is_empty() { + self.lost_sync(insn); } } - // pop / pop2 — drop stack values. - 0x57 /* pop */ => { - self.stack.pop(); - } - 0x58 /* pop2 */ => { - self.stack.pop(); - self.stack.pop(); - } - // anewarray / newarray — pop count, push array ref. Modelled (not - // left to `_`) so array constructions inside a container's arg list - // keep the symbolic stack aligned for per-stream bindings built alongside. - ANEWARRAY | NEWARRAY => { - self.stack.pop(); - self.push(StackVal::Unknown); - } - // aastore — array store consumes 3 slots (arrayref, index, value). - AASTORE => { - for _ in 0..3 { - self.stack.pop(); - } - } - // putstatic / putfield — drop 1 (putstatic) or 2 (putfield). - 0xB3 /* putstatic */ => { - self.stack.pop(); - } - 0xB5 /* putfield */ => { - self.stack.pop(); - self.stack.pop(); - } - // Branches/returns/unhandled — clear stack as a conservative resync. - // Binding `` is straight-line code in practice, so these - // are rarely hit on the verified pattern. - 0xA7 /* goto */ | 0xB1 /* return */ => { - self.stack.clear(); - } - _ => { - // Unknown opcode: best-effort, leave stack untouched. - // The decoder tolerates drift — a final invokespecial - // with mis-aligned stack will just be ignored. - } + // Unconditional control transfer: nothing flows into the next instruction. + GOTO | GOTO_W | IRETURN..=RETURN | ATHROW => self.stack.clear(), + NOP => {} + // Unmodelled (jsr/ret, invokedynamic, reserved): stack effect unknown, so the + // walk is no longer exact. + _ => self.lost_sync(insn), + } + } + + // Pushes a method descriptor's return value (nothing for void). + fn push_return(&mut self, descriptor: &str) { + match descriptor.rsplit_once(')') { + Some((_, "V")) => {} + Some((_, ret)) => self.push(StackVal::of_type(ret)), + None => self.push(StackVal::Unknown), } } } -// Counts argument slots in a JVMS descriptor like `(IILjava/lang/String;LFoo;)V`. -// Each field descriptor is one slot (we don't track the JVM's 2-slot -// long/double layout — the symbolic stack treats every value as 1 slot). +// Counts argument values in a JVMS descriptor like `(IILjava/lang/String;LFoo;)V`. +// One per field descriptor: a long/double arg is one (two-word) `StackVal::Wide`. fn parse_method_arg_count(descriptor: &str) -> usize { let bytes = descriptor.as_bytes(); let mut i = 1; // skip leading '(' @@ -981,6 +1160,8 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - let mut lang_ord: Option = None; let mut purpose_ord: Option = None; let mut coding_type: Option = None; + let mut slot: Option = None; + let mut unknown_coding: Option = None; let mut stream_idx_hint: Option = None; for arg in &c.args { match arg { @@ -989,8 +1170,13 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - "Purpose" => purpose_ord = purpose_ord.or(Some(*ordinal)), _ => {} }, - StackVal::CodingType(name) => { - coding_type = coding_type.or_else(|| Some(name.clone())); + StackVal::CodingType(name) if slot.is_none() => { + slot = coding_slot(name); + if slot == Some(CodingSlot::Audio) { + coding_type = Some(name.clone()); + } else if slot.is_none() { + unknown_coding = Some(name.clone()); + } } StackVal::Int(n) => { stream_idx_hint = stream_idx_hint.or(Some(*n)); @@ -999,11 +1185,15 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - } } + // Interactive graphics / video: numbered in neither the audio nor the PG list. + if slot == Some(CodingSlot::NoSlot) { + continue; + } let Some(lang_ord) = lang_ord else { // A recognisable stream binding still occupies its STN slot and must // advance the counter, or renumbering skews every surviving label. // `saturating_add` is safe here since no label is produced. - match slot_kind(c, coding_type.is_some(), &slot_kinds) { + match slot_kind(c, slot, &slot_kinds) { Some(StreamLabelType::Audio) => audio_idx = audio_idx.saturating_add(1), Some(StreamLabelType::Subtitle) => sub_idx = sub_idx.saturating_add(1), // Not a stream binding (`new StringBuilder` and friends in the @@ -1013,6 +1203,13 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - continue; }; + // An unrecognised CodingType name is not decisive: take the type from siblings. + if let (None, Some(name), Some(StreamLabelType::Audio)) = + (slot, unknown_coding, slot_kind(c, None, &slot_kinds)) + { + coding_type = Some(name); + } + // Audio when a CodingType is present (audio binding type // always references org.bluray.ti.CodingType); subtitle // otherwise. @@ -1098,20 +1295,25 @@ fn slot_kinds(constructions: &[Construction]) -> HashMap<&str, Option> = HashMap::new(); for c in constructions { let mut has_lang = false; - let mut has_coding = false; + let mut slot = None; + let mut unknown = false; for arg in &c.args { match arg { StackVal::EnumRef { kind: "Language", .. } => has_lang = true, - StackVal::CodingType(_) => has_coding = true, + StackVal::CodingType(n) if slot.is_none() => { + slot = coding_slot(n); + unknown |= slot.is_none(); + } _ => {} } } - if !has_lang { + // An unrecognised CodingType name says nothing about the list: not recorded. + if !has_lang || slot == Some(CodingSlot::NoSlot) || (slot.is_none() && unknown) { continue; } - let kind = if has_coding { + let kind = if slot == Some(CodingSlot::Audio) { StreamLabelType::Audio } else { StreamLabelType::Subtitle @@ -1129,17 +1331,39 @@ fn slot_kinds(constructions: &[Construction]) -> HashMap<&str, Option, slot_kinds: &HashMap<&str, Option>, ) -> Option { - if has_coding_type { - return Some(StreamLabelType::Audio); + match slot { + Some(CodingSlot::Audio) => Some(StreamLabelType::Audio), + Some(CodingSlot::Subtitle) => Some(StreamLabelType::Subtitle), + Some(CodingSlot::NoSlot) => None, + None => slot_kinds.get(c.binding_type.as_str()).copied().flatten(), + } +} + +/// STN list a BD-J `CodingType` stream is numbered in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CodingSlot { + Audio, + Subtitle, + /// Interactive graphics or video: no audio or PG slot. + NoSlot, +} + +// Classifies an org.bluray.ti.CodingType field name; None for an unrecognised one. +fn coding_slot(name: &str) -> Option { + match name { + "PRESENTATION_GRAPHICS" | "TEXT_SUBTITLE" => Some(CodingSlot::Subtitle), + "INTERACTIVE_GRAPHICS" => Some(CodingSlot::NoSlot), + n if n.ends_with("_VIDEO") => Some(CodingSlot::NoSlot), + n if n.ends_with("_AUDIO") || n.contains("_AUDIO_") => Some(CodingSlot::Audio), + _ => None, } - slot_kinds.get(c.binding_type.as_str()).copied().flatten() } // Maps an org.bluray.ti.CodingType field name (from getstatic operands on @@ -1149,20 +1373,17 @@ fn coding_type_to_codec_hint(field: &str) -> &str { match field { // Lossless / hi-res. "DOLBY_LOSSLESS_AUDIO" => "Dolby TrueHD", - "DTS_HD_LOSSLESS_AUDIO" | "DTS_HD_MA_AUDIO" => "DTS-HD Master Audio", + "DTS_HD_AUDIO_XLL" => "DTS-HD Master Audio", "LPCM_AUDIO" => "LPCM", // Dolby family. "DOLBY_AC3_AUDIO" => "Dolby Digital", "DOLBY_DIGITAL_PLUS_AUDIO" => "Dolby Digital Plus", - "DOLBY_ATMOS_AUDIO" => "Dolby Atmos", // DTS family. "DTS_AUDIO" => "DTS", - "DTS_HD_AUDIO" | "DTS_HD_HR_AUDIO" => "DTS-HD HR", - // MPEG family. - "MPEG1_AUDIO_LAYER2" | "MPEG2_AUDIO_LAYER2" => "MPEG Audio", - // PG-style subtitle codecs (rare to see in Deluxe bindings; - // subtitles usually have NO CodingType arg). - "PG_STREAM" | "PRESENTATION_GRAPHICS_STREAM" => "PGS", + "DTS_HD_AUDIO" => "DTS-HD", + "DTS_HD_AUDIO_EXCEPT_XLL" => "DTS-HD HR", + "DTS_HD_AUDIO_LBR" => "DTS Express", + "DRA_AUDIO" | "DRA_EXTENSION_AUDIO" => "DRA", // Unknown / future — pass through verbatim so the operator // can see what the disc actually authored. _ => field, @@ -1192,6 +1413,11 @@ fn deluxe_purpose_to_label(ordinal: u16) -> (LabelPurpose, LabelQualifier) { mod tests { use super::*; + // A budget no test fixture can exhaust. + fn unbounded() -> u64 { + u64::MAX + } + // Raw .class/.jar fixture builders: `identify_master_enums`, `find_binding_classes` // and `decode_binding` operate on `jar::Jar` (a real `ZipArchive`), not the // in-memory `ClassFile` struct other tests build, so these need real `.class` bytes. @@ -1610,7 +1836,7 @@ mod tests { ("com/bydeluxe/Decoy.class", decoy), ]); let mut archive = open_jar(zip); - let enums = identify_master_enums(&mut archive); + let enums = identify_master_enums(&mut archive, &mut unbounded()); let purpose = enums .iter() .find(|(label, _)| *label == "Purpose") @@ -1652,7 +1878,7 @@ mod tests { ), ]); let mut archive = open_jar(zip); - let enums = identify_master_enums(&mut archive); + let enums = identify_master_enums(&mut archive, &mut unbounded()); let purpose = enums .iter() .find(|(label, _)| *label == "Purpose") @@ -1681,7 +1907,7 @@ mod tests { let class = class_with_ldc_strings("BoundaryPurpose", &values); let zip = build_zip(&[("com/bydeluxe/B.class", class)]); let mut archive = open_jar(zip); - let enums = identify_master_enums(&mut archive); + let enums = identify_master_enums(&mut archive, &mut unbounded()); assert!( enums.iter().any(|(label, _)| *label == "Purpose"), "a class exactly LDC_COUNT_TOLERANCE away from expected_count must still match" @@ -1695,7 +1921,7 @@ mod tests { let unrelated = class_with_ldc_strings("Unrelated", &["Foo", "Bar"]); let zip = build_zip(&[("x/Unrelated.class", unrelated)]); let mut archive = open_jar(zip); - assert!(identify_master_enums(&mut archive).is_empty()); + assert!(identify_master_enums(&mut archive, &mut unbounded()).is_empty()); } // ── Phase C: find_binding_classes / count_master_enum_getstatic ──────── @@ -1736,7 +1962,7 @@ mod tests { ("com/bydeluxe/D.class", d), ]); let mut archive = open_jar(zip); - let candidates = find_binding_classes(&mut archive, &master_classes); + let candidates = find_binding_classes(&mut archive, &master_classes, &mut unbounded()); let names: Vec<&str> = candidates.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!( names, @@ -1752,13 +1978,32 @@ mod tests { assert_eq!(candidates[1].1, 45); } + // Phase A, C and D share one inflate budget: bytes Phase A inflated are not + // available again to Phase C. + #[test] + fn deluxe_sweeps_share_one_inflate_budget() { + let class = class_with_getstatic_refs("com/bydeluxe/Bind", "com/bydeluxe/Lang", 5); + let len = class.len() as u64; + let mut archive = open_jar(build_zip(&[("com/bydeluxe/Bind.class", class)])); + let master_classes: HashSet<&str> = ["com/bydeluxe/Lang"].into_iter().collect(); + let mut budget = len; + assert!(identify_master_enums(&mut archive, &mut budget).is_empty()); + assert_eq!(budget, 0, "Phase A spent the budget"); + assert!(find_binding_classes(&mut archive, &master_classes, &mut budget).is_empty()); + let mut fresh = len; + assert_eq!( + find_binding_classes(&mut archive, &master_classes, &mut fresh).len(), + 1 + ); + } + #[test] fn find_binding_classes_empty_master_set_yields_no_candidates() { let master_classes: HashSet<&str> = HashSet::new(); let a = class_with_getstatic_refs("A", "LanguageEnum", 100); let zip = build_zip(&[("com/bydeluxe/A.class", a)]); let mut archive = open_jar(zip); - assert!(find_binding_classes(&mut archive, &master_classes).is_empty()); + assert!(find_binding_classes(&mut archive, &master_classes, &mut unbounded()).is_empty()); } // ── Phase D: decode_binding (Jar-level short-circuit wrapper) ─────────── @@ -1777,7 +2022,13 @@ mod tests { let mut archive = open_jar(zip); let master = lang_enum_master(); - let ctors = decode_binding(&mut archive, "com/bydeluxe/Target.class", &master); + let ctors = decode_binding( + &mut archive, + "com/bydeluxe/Target.class", + &master, + &mut unbounded(), + ) + .constructions; assert_eq!( ctors.len(), 1, @@ -1785,9 +2036,18 @@ mod tests { ); assert_eq!(ctors[0].binding_type, "AudioSlot"); - // A name with no matching entry must yield nothing (try_each_class + // A name with no matching entry must yield nothing (the sweep // never finds a Some). - assert!(decode_binding(&mut archive, "com/bydeluxe/NoSuchClass.class", &master).is_empty()); + assert!( + decode_binding( + &mut archive, + "com/bydeluxe/NoSuchClass.class", + &master, + &mut unbounded() + ) + .constructions + .is_empty() + ); } // ── Phase D bytecode walker tests ─────────────────────────────────────── @@ -2257,7 +2517,7 @@ mod tests { ]; let class = class_with_clinit(pool, 4, &code); let master = lang_enum_master(); - let constructions = decode_binding_class(&class, &master); + let constructions = decode_binding_class(&class, &master).constructions; assert_eq!( constructions.len(), 1, @@ -2472,6 +2732,146 @@ mod tests { ); } + // The symbolic stack after running `code` (pool: `AnyClass.m()J` at cp 6), + // rendered as ints, "W" (long/double), "?" (opaque) or "new X". + fn stack_after(code: &[u8]) -> Vec { + let pool = call_ref_pool("()J"); + let master = lang_enum_master(); + let attr = super::super::class_reader::CodeAttribute { + max_stack: 16, + max_locals: 0, + code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + decoder + .stack + .iter() + .map(|v| match v { + StackVal::Int(n) => n.to_string(), + StackVal::Wide => "W".into(), + StackVal::NewObj(c) => format!("new {c}"), + _ => "?".into(), + }) + .collect() + } + + // JVMS §6.5 dup_x1/dup_x2/dup2/dup2_x1/dup2_x2/swap, every category-1/2 form. + #[test] + fn binding_decoder_models_the_dup_family_and_swap() { + let (c1, c2, c3, c4, l0) = (ICONST_1, ICONST_2, ICONST_3, ICONST_4, 0x09); + let cases: &[(&[u8], &[&str])] = &[ + (&[c1, c2, 0x5A], &["2", "1", "2"]), + (&[c1, c2, c3, 0x5B], &["3", "1", "2", "3"]), + (&[l0, c1, 0x5B], &["1", "W", "1"]), + (&[c1, c2, 0x5C], &["1", "2", "1", "2"]), + (&[l0, 0x5C], &["W", "W"]), + (&[c1, c2, c3, 0x5D], &["2", "3", "1", "2", "3"]), + (&[c1, l0, 0x5D], &["W", "1", "W"]), + (&[c1, c2, c3, c4, 0x5E], &["3", "4", "1", "2", "3", "4"]), + (&[c1, c2, l0, 0x5E], &["W", "1", "2", "W"]), + (&[l0, c1, c2, 0x5E], &["1", "2", "W", "1", "2"]), + (&[l0, l0, 0x5E], &["W", "W", "W"]), + (&[c1, c2, 0x5F], &["2", "1"]), + ]; + for (code, want) in cases { + assert_eq!(stack_after(code), *want, "{code:02x?}"); + } + } + + // pop2 removes one long/double or two category-1 values; pop never splits one. + #[test] + fn binding_decoder_pop2_respects_value_categories() { + assert_eq!(stack_after(&[ICONST_1, 0x09, POP2]), ["1"]); + assert_eq!(stack_after(&[ICONST_1, 0x0E, POP2]), ["1"]); + assert_eq!(stack_after(&[ICONST_1, LDC2_W, 0, 1, POP2]), ["1"]); + // A discarded J-returning call. + assert_eq!(stack_after(&[ICONST_1, INVOKESTATIC, 0, 6, POP2]), ["1"]); + assert_eq!(stack_after(&[ICONST_1, ICONST_2, ICONST_3, POP2]), ["1"]); + } + + // Local loads push, stores pop (JVMS §6.5 xload/xstore, incl. wide forms). + #[test] + fn binding_decoder_models_local_loads_and_stores() { + assert_eq!( + stack_after(&[0x1A, 0x15, 3, 0x2A, 0x19, 3]), + ["?", "?", "?", "?"] + ); + assert_eq!( + stack_after(&[0x1E, 0x16, 3, 0x26, 0x18, 3]), + ["W", "W", "W", "W"] + ); + assert_eq!( + stack_after(&[ICONST_1, ICONST_2, 0x3B, 0x36, 1]), + [] as [&str; 0] + ); + assert_eq!(stack_after(&[ICONST_1, 0x09, 0x3F]), ["1"]); + assert_eq!( + stack_after(&[0xC4, 0x16, 0, 3, 0xC4, 0x15, 0, 4]), + ["W", "?"] + ); + // Array loads pop arrayref + index. + assert_eq!(stack_after(&[ICONST_1, ICONST_2, 0x2F]), ["W"]); + assert_eq!(stack_after(&[ICONST_1, ICONST_2, 0x2E]), ["?"]); + } + + // Arithmetic, conversions, compares and conditional branches (JVMS §6.5). + #[test] + fn binding_decoder_models_arithmetic_compares_and_branches() { + assert_eq!(stack_after(&[ICONST_1, ICONST_2, 0x60]), ["?"]); + assert_eq!(stack_after(&[0x09, 0x0A, 0x61]), ["W"]); + assert_eq!(stack_after(&[0x0E, 0x0E, 0x6F]), ["W"]); + assert_eq!(stack_after(&[0x09, ICONST_1, 0x79]), ["W"]); + assert_eq!(stack_after(&[ICONST_3, 0x74]), ["?"]); + assert_eq!(stack_after(&[ICONST_1, 0x85]), ["W"]); + assert_eq!(stack_after(&[0x09, 0x88]), ["?"]); + assert_eq!(stack_after(&[0x09, 0x0A, 0x94]), ["?"]); + assert_eq!(stack_after(&[ICONST_1, 0x84, 0, 1]), ["1"]); + assert_eq!(stack_after(&[ICONST_1, ICONST_2, 0x99, 0, 3]), ["1"]); + assert_eq!( + stack_after(&[ICONST_1, ICONST_2, 0x9F, 0, 3]), + [] as [&str; 0] + ); + assert_eq!(stack_after(&[ICONST_1, ACONST_NULL, 0xC6, 0, 3]), ["1"]); + assert_eq!(stack_after(&[ICONST_1, ACONST_NULL, 0xBE]), ["1", "?"]); + } + + // A local load feeding a binding ctor stays aligned with the ctor's args. + #[test] + fn binding_decoder_local_load_feeds_ctor_args() { + let pool = int_ctor_pool(); + let master = lang_enum_master(); + // new; dup; bipush 7; iconst_1; istore_0; lload_1; pop2; invokespecial (I)V + let code = [ + NEW, + 0, + 2, + DUP, + BIPUSH, + 7, + ICONST_1, + 0x3B, + 0x1F, + POP2, + INVOKESPECIAL, + 0, + 6, + ]; + let attr = super::super::class_reader::CodeAttribute { + max_stack: 4, + max_locals: 2, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + assert_eq!(decoder.constructions.len(), 1); + assert!(matches!( + decoder.constructions[0].args[..], + [StackVal::Int(7)] + )); + assert_eq!(decoder.drift, 0); + } + // Pool for a single-int-arg constructor `AudioSlot.(I)V`, used to // isolate each int-push opcode's (iconst_, bipush, sipush, ldc) exact // produced value, not just "a construction happened". @@ -2538,8 +2938,8 @@ mod tests { #[test] fn binding_decoder_skips_unmatched_invokespecial() { - // invokespecial without a preceding `new X; dup` — should - // produce zero constructions. + // invokespecial without a preceding `new X; dup`: no construction with + // args, only a drift event and an argless slot placeholder. let code: Vec = vec![ICONST_0, GETSTATIC, 0, 6, INVOKESPECIAL, 0, 12]; let pool = build_simple_pool(); let master = lang_enum_master(); @@ -2550,7 +2950,8 @@ mod tests { }; let mut decoder = BindingDecoder::new(&pool, &master); decoder.run(&attr); - assert_eq!(decoder.constructions.len(), 0); + assert!(decoder.constructions.iter().all(|c| c.args.is_empty())); + assert_eq!(decoder.drift, 1); } #[test] @@ -2616,6 +3017,177 @@ mod tests { } } + // A `` whose receiver is not its own `new` lost its operands: it still + // owns its STN slot, so later bindings keep their numbers. + #[test] + fn mismatched_ctor_receiver_keeps_later_slots_aligned() { + let base = build_simple_pool(); + let mut entries: Vec = (0..13).filter_map(|i| base.get(i).cloned()).collect(); + entries.extend([ + CpInfo::Utf8("French".into()), // 13 + CpInfo::NameAndType { + name_index: 13, + descriptor_index: 4, + }, // 14 + CpInfo::Fieldref { + class_index: 2, + name_and_type_index: 14, + }, // 15 + CpInfo::Utf8("Other".into()), // 16 + CpInfo::Class { name_index: 16 }, // 17 + ]); + let pool = ConstantPool::from_entries(entries); + let ctor = |lang: u8| { + [ + NEW, + 0, + 8, + DUP, + GETSTATIC, + 0, + lang, + INVOKESPECIAL, + 0, + 12, + POP, + ] + }; + let mut code = ctor(6).to_vec(); + // new AudioSlot; dup; new Other; getstatic English; invokespecial AudioSlot. + code.extend([ + NEW, + 0, + 8, + DUP, + NEW, + 0, + 17, + GETSTATIC, + 0, + 6, + INVOKESPECIAL, + 0, + 12, + POP, + POP, + ]); + code.extend(ctor(15)); + let master = lang_enum_master(); + let attr = super::super::class_reader::CodeAttribute { + max_stack: 4, + max_locals: 0, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + let labels = interpret_streams(&decoder.constructions, &master); + let got: Vec<_> = labels + .iter() + .map(|l| (l.language.as_str(), l.stream_number)) + .collect(); + assert_eq!(got, vec![("eng", 1), ("fra", 3)]); + } + + // Two-class Deluxe jar on a disc: a 70-value Language enum and a binding class + // of four `new Slot(Lang.English)`, each preceded by `extra` bytecode. + fn deluxe_disc(extra: &[u8]) -> (crate::udf::fixture::MemDisc, crate::udf::UdfFs) { + use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir}; + let mut values = vec!["English", "French", "Spanish", "Dutch"]; + let rest: Vec = (4..70).map(|i| format!("L{i}")).collect(); + values.extend(rest.iter().map(String::as_str)); + let lang = class_with_ldc_strings("com/bydeluxe/Lang", &values); + let cp = vec![ + CpInfo::Empty, + CpInfo::Utf8("".into()), + CpInfo::Utf8("()V".into()), + CpInfo::Utf8("Code".into()), + CpInfo::Utf8("com/bydeluxe/Lang".into()), + CpInfo::Class { name_index: 4 }, + CpInfo::Utf8("English".into()), + CpInfo::Utf8("Lcom/bydeluxe/Lang;".into()), + CpInfo::NameAndType { + name_index: 6, + descriptor_index: 7, + }, + CpInfo::Fieldref { + class_index: 5, + name_and_type_index: 8, + }, + CpInfo::Utf8("Slot".into()), + CpInfo::Class { name_index: 10 }, + CpInfo::Utf8("".into()), + CpInfo::Utf8("(Lcom/bydeluxe/Lang;)V".into()), + CpInfo::NameAndType { + name_index: 12, + descriptor_index: 13, + }, + CpInfo::Methodref { + class_index: 11, + name_and_type_index: 14, + }, + CpInfo::Utf8("com/bydeluxe/Bind".into()), + CpInfo::Class { name_index: 16 }, + ]; + let mut code = Vec::new(); + for _ in 0..4 { + code.extend([NEW, 0, 11, DUP]); + code.extend_from_slice(extra); + code.extend([GETSTATIC, 0, 9, INVOKESPECIAL, 0, 15, POP]); + } + code.push(RETURN); + let bind = encode_class( + &cp, + 17, + &[MethodSpec { + name_index: 1, + descriptor_index: 2, + code_attr_name_index: 3, + max_stack: 4, + code, + }], + ); + let jar = build_zip(&[ + ("com/bydeluxe/Lang.class", lang), + ("com/bydeluxe/Bind.class", bind), + ]); + let dir = |name: &str, icb, files, subdirs| DirSpec { + name: name.to_string(), + icb_lba: icb, + dir_data_lba: icb + 1, + files, + subdirs, + }; + let jar_dir = dir( + "JAR", + 30, + vec![file_with("00000.jar", 32, 4000, jar, true)], + vec![], + ); + let root = dir("", 10, vec![], vec![dir("BDMV", 20, vec![], vec![jar_dir])]); + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + (disc, udf) + } + + #[test] + fn exact_binding_decode_is_high_confidence() { + let (mut disc, udf) = deluxe_disc(&[]); + let r = parse(&mut disc, &udf).expect("deluxe labels"); + assert_eq!(r.labels.len(), 4); + assert_eq!(r.confidence, super::super::Confidence::High); + } + + // An opcode the walker does not model (here `jsr`, which pushes) before a + // binding ctor: its labels may sit on the wrong slots, so never High. + #[test] + fn unmodelled_opcode_before_a_binding_ctor_is_not_high_confidence() { + let (mut disc, udf) = deluxe_disc(&[0xA8, 0, 3]); + let r = parse(&mut disc, &udf).expect("deluxe labels"); + assert_eq!(r.confidence, super::super::Confidence::Low); + } + // ── interpret_streams + deluxe_purpose_to_label tests ─────────────────── #[test] @@ -2798,10 +3370,120 @@ mod tests { ); assert_eq!(coding_type_to_codec_hint("DTS_AUDIO"), "DTS"); assert_eq!( - coding_type_to_codec_hint("DTS_HD_MA_AUDIO"), + coding_type_to_codec_hint("DTS_HD_AUDIO_XLL"), "DTS-HD Master Audio" ); assert_eq!(coding_type_to_codec_hint("LPCM_AUDIO"), "LPCM"); + assert_eq!(coding_type_to_codec_hint("DRA_AUDIO"), "DRA"); + assert_eq!(coding_type_to_codec_hint("DRA_EXTENSION_AUDIO"), "DRA"); + } + + #[test] + fn graphics_coding_type_binding_is_not_audio() { + let master = lang_enum_master(); + let mk = |ct: &str| Construction { + binding_type: "x".into(), + args: vec![ + StackVal::EnumRef { + kind: "Language", + ordinal: 0, + }, + StackVal::CodingType(ct.into()), + ], + }; + let out = interpret_streams(&[mk("PRESENTATION_GRAPHICS"), mk("DTS_AUDIO")], &master); + assert_eq!(out.len(), 2); + assert_eq!(out[0].stream_type, StreamLabelType::Subtitle); + assert_eq!( + (out[1].stream_type, out[1].stream_number), + (StreamLabelType::Audio, 1) + ); + } + + // Interactive-graphics and video bindings have no audio or PG STN slot: with or + // without a resolved language they must not shift later subtitle numbers. + #[test] + fn interactive_graphics_and_video_bindings_take_no_subtitle_slot() { + let master = lang_enum_master(); + let mk = |ct: &str, lang: Option| Construction { + binding_type: "x".into(), + args: vec![ + lang.map_or(StackVal::Unknown, |ordinal| StackVal::EnumRef { + kind: "Language", + ordinal, + }), + StackVal::CodingType(ct.into()), + ], + }; + let out = interpret_streams( + &[ + mk("INTERACTIVE_GRAPHICS", Some(2)), + mk("PRESENTATION_GRAPHICS", Some(0)), + mk("MPEG4_AVC_VIDEO", Some(2)), + mk("INTERACTIVE_GRAPHICS", None), + mk("TEXT_SUBTITLE", Some(1)), + ], + &master, + ); + let got: Vec<_> = out + .iter() + .map(|l| (l.stream_type, l.language.as_str(), l.stream_number)) + .collect(); + assert_eq!( + got, + vec![ + (StreamLabelType::Subtitle, "eng", 1), + (StreamLabelType::Subtitle, "fra", 2), + ] + ); + } + + // An unrecognised CodingType name is not decisive: a resolved-language construction + // takes its list from its binding type's siblings, and records nothing itself. + #[test] + fn unknown_coding_type_takes_sibling_kind_and_is_not_recorded() { + let mk = |bt: &str, ct: &str, ordinal: u16| Construction { + binding_type: bt.into(), + args: vec![ + StackVal::EnumRef { + kind: "Language", + ordinal, + }, + StackVal::CodingType(ct.into()), + ], + }; + let cs = [ + mk("T", "DTS_AUDIO", 0), + mk("T", "FUTURE_CODEC_X", 1), + mk("U", "FUTURE_CODEC_X", 2), + ]; + assert!(!slot_kinds(&cs).contains_key("U")); + let out = interpret_streams(&cs[..2], &lang_enum_master()); + let got: Vec<_> = out + .iter() + .map(|l| (l.stream_type, l.stream_number)) + .collect(); + assert_eq!( + got, + vec![(StreamLabelType::Audio, 1), (StreamLabelType::Audio, 2)] + ); + } + + // Bytecode the iterator cannot size (a truncated ldc) ends the walk early: the rest + // goes undecoded, so it counts as drift. + #[test] + fn binding_decoder_counts_early_end_of_walk_as_drift() { + let code: Vec = vec![0x00, LDC]; // ldc lacks its operand + let pool = build_simple_pool(); + let master = lang_enum_master(); + let attr = super::super::class_reader::CodeAttribute { + max_stack: 4, + max_locals: 0, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + assert_eq!(decoder.drift, 1); } #[test] @@ -2911,11 +3593,18 @@ mod tests { fn universal_binding_class_decodes_real_per_stream_labels() { let tl = parse_fixture(UNI_TL_CLASS); let master = universal_master(); - let constructions = decode_binding_class(&tl, &master); + let decoded = decode_binding_class(&tl, &master); + assert_eq!(decoded.drift, 0, "the real binding table decodes exactly"); + let constructions = decoded.constructions; assert!( !constructions.is_empty(), "tl. must yield per-stream constructions" ); + // Every `np` audio binding (21) and `wb` subtitle binding (46) must survive + // the long-constant / array-store stack modelling. + let count = |t: &str| constructions.iter().filter(|c| c.binding_type == t).count(); + let (np, wb) = (count("np"), count("wb")); + assert_eq!((np, wb), (21, 46), "np/wb constructions recovered"); // The title-wrapper `oq` takes array parameters and must be filtered: // every retained construction is a scalar/enum-only binding. // (np = 4 args incl. CodingType; wb = 4-5 args incl. `mi`.) diff --git a/src/labels/fox.rs b/src/labels/fox.rs index 0fd0dd91..c597504a 100644 --- a/src/labels/fox.rs +++ b/src/labels/fox.rs @@ -43,10 +43,7 @@ pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { if super::jar_file_exists(udf, "dcx.xml") { return true; } - super::jar::for_each_jar(reader, udf, |_, jar| { - super::jar::has_path_prefix(jar, "com/foxbd/").then_some(()) - }) - .is_some() + super::jar::any_jar_has_prefix(reader, udf, "com/foxbd/") } pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { @@ -88,6 +85,9 @@ pub(crate) fn labels_from_dcx(text: &str) -> Vec { .unwrap_or_default() } +/// Per-type label cap; dcx.xml is untrusted disc input. +const MAX_LABELS_PER_TYPE: usize = 512; + // Stream labels from the selected `` element. fn labels_from_feature(feature: &str) -> Vec { let mut labels = Vec::new(); @@ -95,6 +95,9 @@ fn labels_from_feature(feature: &str) -> Vec { // Audio streams. let mut from = 0; while let Some((s, e)) = xml::find_element(feature, "audio", from) { + if labels.len() >= MAX_LABELS_PER_TYPE { + break; + } let el = &feature[s..e]; from = e; let Some(stream_number) = stream_number_from_id(&xml::attr(el, "id")) else { @@ -121,7 +124,11 @@ fn labels_from_feature(feature: &str) -> Vec { // Subtitle streams. let mut from = 0; + let audio_count = labels.len(); while let Some((s, e)) = xml::find_element(feature, "subtitle", from) { + if labels.len() - audio_count >= MAX_LABELS_PER_TYPE { + break; + } let el = &feature[s..e]; from = e; let Some(stream_number) = stream_number_from_id(&xml::attr(el, "id")) else { @@ -464,6 +471,17 @@ mod tests { } /// `id="NN"` becomes the 1-based STN slot; a missing/zero id names no slot. + #[test] + fn labels_per_type_are_capped() { + let mut f = String::new(); + for i in 1..=2000 { + f.push_str(&format!("