mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca732d57e7 | ||
|
|
f357e0fe26 | ||
|
|
976aeb4848 | ||
|
|
2bea69c7d9 | ||
|
|
0bb815ca22 | ||
|
|
aa188751e4 | ||
|
|
ba44f7d928 | ||
|
|
8a95787426 | ||
|
|
aebe6a256b | ||
|
|
791b832f25 | ||
|
|
580ce2277d | ||
|
|
8a556c5c63 | ||
|
|
520a3f912c | ||
|
|
6f0eea48fa | ||
|
|
b01010f2d1 | ||
|
|
fd443e3a2f | ||
|
|
8d85a9d778 | ||
|
|
237528b385 | ||
|
|
492a2966c0 | ||
|
|
5fa6b064da | ||
|
|
df652c5735 | ||
|
|
d0c5c7fb97 | ||
|
|
6ab355245f | ||
|
|
272215c551 | ||
|
|
721308cb8e | ||
|
|
cc58ab1754 | ||
|
|
193b571967 | ||
|
|
bac2e39ee5 | ||
|
|
9825db82aa | ||
|
|
985d00f0a4 | ||
|
|
dbc5789406 | ||
|
|
eb3b84216a | ||
|
|
a07a7aebf2 | ||
|
|
37ee573014 | ||
|
|
b34b7dd2e6 |
@@ -0,0 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0 (2026-04-07)
|
||||
|
||||
### Labels — complete rewrite
|
||||
|
||||
- **Detect-then-parse architecture**: each BD-J authoring format has its own parser module with `detect()` and `parse()` functions. Drop in a new parser with one line in the registry.
|
||||
- **5 format parsers**: Paramount (`playlists.xml`), Criterion (`streamproperties.xml`), Pixelogic (`bluray_project.bin`), Warner CTRM (`menu_base.prop` / `language_streams.txt`), shared label vocabulary (`vocab.rs`)
|
||||
- **Raw disc data principle**: label data passes through as-is from disc. Only BD-standard codec identifiers (MLP, AC3, DTS) are mapped to display names. Unknown authoring tool codes (csp, eda, cf) pass through raw.
|
||||
- **`variant` field**: replaces `region` — language dialect codes from authoring tools, not BD spec regions
|
||||
- Removed: old `jar` module (superseded by labels)
|
||||
- Removed: dead label apply functions from disc.rs
|
||||
|
||||
### Drive
|
||||
|
||||
- **`DriveSession::eject()`**: sends PREVENT ALLOW MEDIUM REMOVAL then START STOP UNIT. Works reliably after raw mode unlock.
|
||||
- **`DiscRegion` enum**: Free, BluRay(A/B/C), Dvd(1-8). UHD always region-free.
|
||||
|
||||
### Capture
|
||||
|
||||
- **Fixed sector range collection**: captures ALL files on disc (only skips STREAM/ video files and >50MB). Previously skipped BACKUP/, DUPLICATE/, and files >10MB which missed JAR content.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
- Labels module: 4 disc file parsers for stream labels
|
||||
- Simplified labels API
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Initial public release
|
||||
- SCSI transport (Linux SG_IO, macOS IOKit)
|
||||
- UDF 2.50 filesystem reader
|
||||
- MPLS/CLPI parsers with full STN support
|
||||
- Drive identification + profile matching
|
||||
- 206 bundled drive profiles
|
||||
- AACS 1.0 decryption (VUK + unit keys)
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
@@ -15,14 +15,16 @@ sha1 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
flate2 = "1"
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "freemkv-info"
|
||||
path = "src/bin/freemkv_info.rs"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "freemkv-test"
|
||||
path = "src/bin/freemkv_test.rs"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
[](https://crates.io/crates/libfreemkv)
|
||||
[](https://docs.rs/libfreemkv)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for raw sector access on optical drives. Identifies drives using standard SCSI commands, matches them against 206 bundled profiles, and unlocks raw read mode. No external files, no configuration.
|
||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
|
||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
|
||||
Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
@@ -12,113 +14,84 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.2"
|
||||
libfreemkv = "0.4"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use libfreemkv::DriveSession;
|
||||
use libfreemkv::{DriveSession, Disc, ScanOptions};
|
||||
use std::path::Path;
|
||||
|
||||
// Open drive — profiles are bundled, auto-identified
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
|
||||
session.unlock()?; // activate raw read mode
|
||||
session.calibrate()?; // optimize read speed
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let n = session.read_sectors(0, 1, &mut buf)?;
|
||||
```
|
||||
for title in &disc.titles {
|
||||
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
}
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **INQUIRY** (SPC-4 §6.4) — reads vendor_id, product_id, product_revision, vendor_specific
|
||||
2. **GET CONFIGURATION 010C** (MMC-6 §5.3.10) — reads firmware_date
|
||||
3. **Profile match** — five fields uniquely identify the drive against 206 bundled profiles
|
||||
4. **READ BUFFER** — single command with per-drive mode/buffer_id activates raw read mode
|
||||
5. **Signature verify** — drive responds with 4-byte signature + "MMkv" confirmation
|
||||
|
||||
No fingerprints. No encrypted lookups. All identification uses standard SCSI fields.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
let mut session = DriveSession::open(device_path)?;
|
||||
|
||||
// Drive identity
|
||||
session.drive_id.vendor_id // "HL-DT-ST"
|
||||
session.drive_id.product_id // "BD-RE BU40N"
|
||||
session.drive_id.product_revision // "1.03"
|
||||
session.drive_id.vendor_specific // "NM00000"
|
||||
session.drive_id.firmware_date // "211810241934"
|
||||
|
||||
// Profile data
|
||||
session.profile.chipset // Chipset::MediaTek
|
||||
session.profile.unlock_mode // 0x01
|
||||
session.profile.unlock_buf_id // 0x44
|
||||
session.profile.signature // [0x99, 0x9e, 0xc3, 0x75]
|
||||
|
||||
// Operations
|
||||
session.unlock()?;
|
||||
session.calibrate()?;
|
||||
session.read_sectors(lba, count, &mut buf)?;
|
||||
session.status()?;
|
||||
session.read_config()?;
|
||||
session.read_register(index)?;
|
||||
```
|
||||
|
||||
## Chipset Support
|
||||
|
||||
| Chipset | Status | Drives | Brands |
|
||||
|---------|--------|--------|--------|
|
||||
| MediaTek MT1959 | Supported | 206 | LG, ASUS, HP |
|
||||
| Renesas | Planned | -- | Pioneer |
|
||||
|
||||
## Drive Profile
|
||||
|
||||
Each bundled profile (compiled into the binary):
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_id": "HL-DT-ST",
|
||||
"product_id": "BD-RE BU40N ",
|
||||
"product_revision": "1.03",
|
||||
"vendor_specific": "NM00000",
|
||||
"firmware_date": "211810241934",
|
||||
"chipset": "mediatek",
|
||||
"unlock_mode": 1,
|
||||
"unlock_buf_id": 68,
|
||||
"signature": "999ec375",
|
||||
"register_offsets": ["10e291", "11ab1c"]
|
||||
// Read content (decrypted transparently if AACS keys available)
|
||||
let mut reader = disc.open_title(&mut session, 0)?;
|
||||
while let Some(unit) = reader.read_unit()? {
|
||||
// 6144 bytes of content per aligned unit
|
||||
}
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock for raw reads
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0)
|
||||
- **Content reading** — sector reads with automatic decryption
|
||||
|
||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
DriveSession — open, identify, unlock, read sectors
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── Platform — MediaTek (supported), Renesas (planned)
|
||||
|
||||
Disc — scan titles, streams, AACS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
├── MPLS parser — playlists → titles + clips + streams
|
||||
├── CLPI parser — clip info → EP map → sector extents
|
||||
├── JAR parser — BD-J audio track labels
|
||||
└── AACS — key resolution + content decryption
|
||||
```
|
||||
|
||||
See [docs/](docs/) for detailed technical documentation on each module.
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Error | Meaning |
|
||||
|------|-------|---------|
|
||||
| E1000 | DeviceNotFound | Device path doesn't exist |
|
||||
| E1001 | DevicePermission | No access (try sudo or cdrom group) |
|
||||
| E2000 | UnsupportedDrive | No matching profile |
|
||||
| E3000 | UnlockFailed | Unlock command rejected |
|
||||
| E3001 | SignatureMismatch | Response signature wrong |
|
||||
| E3002 | NotUnlocked | Operation requires unlock first |
|
||||
| E4000 | ScsiError | SCSI command failed |
|
||||
| E5000 | IoError | System I/O error |
|
||||
All errors are structured with numeric codes. No user-facing English text — applications format their own messages.
|
||||
|
||||
## Platform
|
||||
| Range | Category |
|
||||
|-------|----------|
|
||||
| E1xxx | Device errors (not found, permission) |
|
||||
| E2xxx | Profile errors (unsupported drive) |
|
||||
| E3xxx | Unlock errors (failed, signature) |
|
||||
| E4xxx | SCSI errors (command failed, timeout) |
|
||||
| E5xxx | I/O errors |
|
||||
| E6xxx | Disc format errors |
|
||||
| E7xxx | AACS errors |
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Status | Backend |
|
||||
|----------|--------|---------|
|
||||
| Linux | Supported | SG_IO ioctl |
|
||||
| macOS | Planned | IOKit |
|
||||
| macOS | Supported | IOKit SCSITask |
|
||||
| Windows | Planned | SPTI |
|
||||
|
||||
The `ScsiTransport` trait abstracts the platform. Adding a backend is one file behind a `cfg` gate.
|
||||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to submit your drive's profile.
|
||||
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fn main() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# libfreemkv Documentation
|
||||
|
||||
Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv), the open source optical drive library.
|
||||
|
||||
## Start Here
|
||||
|
||||
**[Disc to Rip: End-to-End Flow](disc-to-rip.md)** — How the library goes from a disc in the drive to decrypted content. Read this first.
|
||||
|
||||
## Reference
|
||||
|
||||
| Document | What it covers |
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | DriveSession, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
|
||||
|
||||
## Reading Order
|
||||
|
||||
If you want to understand the whole library:
|
||||
|
||||
1. **[Disc to Rip](disc-to-rip.md)** — the big picture
|
||||
2. **[Architecture](architecture.md)** — how modules fit together
|
||||
3. **[Drive Access](drive-access.md)** — how we talk to hardware
|
||||
4. **[UDF](udf.md)** → **[MPLS](mpls.md)** → **[CLPI](clpi.md)** — how disc content is structured
|
||||
5. **[AACS](aacs.md)** — how encryption works and how we break it
|
||||
|
||||
## API Documentation
|
||||
|
||||
Generated API docs are on [docs.rs/libfreemkv](https://docs.rs/libfreemkv).
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
# AACS Encryption Support
|
||||
|
||||
## Overview
|
||||
|
||||
AACS (Advanced Access Content System) is the encryption layer used by Blu-ray and UHD 4K discs to protect content. libfreemkv implements AACS decryption to enable transparent disc access.
|
||||
|
||||
There are two major versions:
|
||||
|
||||
- **AACS 1.0** -- Used by standard Blu-ray discs. Relies on a custom 160-bit elliptic curve for bus authentication and AES-128 for content encryption. Processing keys and device keys can derive the media key from the disc's Media Key Block (MKB).
|
||||
|
||||
- **AACS 2.0** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus encryption layer (read_data_key) on top of the standard content encryption. Uses P-256/SHA-256 for its native handshake, though drives accept AACS 1.0 host certificates for backward compatibility.
|
||||
|
||||
Both versions use AES-128-CBC for content decryption with a fixed initialization vector. The fundamental key hierarchy is the same: a Volume Unique Key (VUK) decrypts per-title unit keys, which in turn decrypt the content stream.
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
AACS support is split across two modules:
|
||||
|
||||
### `aacs.rs` -- Keys and Decryption
|
||||
|
||||
Handles everything related to key resolution and content decryption:
|
||||
|
||||
- KEYDB.cfg parsing (device keys, processing keys, host certificates, per-disc entries)
|
||||
- Disc hash computation (SHA-1 of `Unit_Key_RO.inf`)
|
||||
- VUK resolution chain (4 paths, described below)
|
||||
- MKB record parsing and media key derivation
|
||||
- Subset-difference tree traversal (AACS-G3 key derivation)
|
||||
- Unit_Key_RO.inf parsing and unit key decryption
|
||||
- Content Certificate parsing (AACS version detection)
|
||||
- Aligned unit decryption (AES-128-CBC)
|
||||
- Bus decryption (AACS 2.0 read_data_key layer)
|
||||
|
||||
### `aacs_handshake.rs` -- SCSI Authentication
|
||||
|
||||
Handles the drive-level SCSI authentication protocol:
|
||||
|
||||
- ECDH key agreement on the AACS 160-bit curve
|
||||
- ECDSA signing and verification
|
||||
- Bus key derivation
|
||||
- AGID management (allocate/invalidate)
|
||||
- Volume ID retrieval (encrypted with bus key, verified by AES-CMAC)
|
||||
- Read Data Key retrieval (for AACS 2.0 bus decryption)
|
||||
- AACS LA public key certificate verification
|
||||
|
||||
|
||||
## Key Resolution Chain
|
||||
|
||||
When a disc is scanned, `resolve_keys()` attempts four paths in priority order. The first path that succeeds is used.
|
||||
|
||||
### Path 1: KEYDB VUK Lookup (fastest)
|
||||
|
||||
```
|
||||
Unit_Key_RO.inf --> SHA-1 --> disc_hash --> KEYDB lookup --> VUK
|
||||
```
|
||||
|
||||
The disc hash is computed as the SHA-1 digest of the raw `Unit_Key_RO.inf` file from the disc's `/AACS/` directory. This hash is used as the lookup key in `KEYDB.cfg`. If a matching entry contains a VUK (`V` field), it is used directly.
|
||||
|
||||
This is the fast path and resolves the vast majority of discs in a well-maintained KEYDB.
|
||||
|
||||
### Path 2: KEYDB Media Key + Volume ID
|
||||
|
||||
```
|
||||
KEYDB media_key + Volume ID (from SCSI handshake) --> VUK derivation
|
||||
```
|
||||
|
||||
If the disc hash is not in the KEYDB but a KEYDB entry has a matching Volume ID (`I` field) and a media key (`M` field), the VUK is derived:
|
||||
|
||||
```
|
||||
VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||
```
|
||||
|
||||
Requires a successful SCSI handshake to obtain the Volume ID.
|
||||
|
||||
### Path 3: MKB + Processing Keys
|
||||
|
||||
```
|
||||
MKB (from disc) + processing_keys (from KEYDB) --> media_key --> VUK
|
||||
```
|
||||
|
||||
Processing keys are pre-computed keys that work against specific MKB versions. For each processing key, the library:
|
||||
|
||||
1. Parses the MKB to extract the Verify Media Key Record (`mk_dv`), subset-difference index, and conditional values (cvalues).
|
||||
2. Tries each processing key against each UV/cvalue pair: `mk = AES-DEC(pk, cvalue) XOR cvalue`.
|
||||
3. Validates the derived media key: `AES-ECB(mk, mk_dv)` must produce 12 leading zero bytes.
|
||||
4. Derives VUK from the validated media key and Volume ID.
|
||||
|
||||
### Path 4: MKB + Device Keys (Subset-Difference Tree)
|
||||
|
||||
```
|
||||
MKB + device_keys --> subset-difference tree traversal --> processing_key --> media_key --> VUK
|
||||
```
|
||||
|
||||
The most complex path. Each device key has an associated node number, UV value, and mask parameters that position it in the AACS subset-difference tree. The library:
|
||||
|
||||
1. Finds the subset-difference entry in the MKB that applies to the device key's node.
|
||||
2. Traverses the tree using AACS-G3 key derivation: `aesg3(key, inc) = AES-DEC(key, seed) XOR seed`, where `seed[15]` is incremented by `inc`. Each tree node produces a left child (inc=0), a processing key (inc=1), and a right child (inc=2).
|
||||
3. At each level, selects left or right based on the UV bit at the current position.
|
||||
4. The resulting processing key is validated against the MKB cvalue to derive the media key.
|
||||
5. VUK is derived from the media key and Volume ID.
|
||||
|
||||
|
||||
## Content Decryption
|
||||
|
||||
### Aligned Units
|
||||
|
||||
AACS encrypts content in aligned units of 6144 bytes (3 sectors of 2048 bytes each). The encryption flag is signaled by the copy_permission_indicator bits in byte 0 of the unit (`unit[0] & 0xC0 != 0`).
|
||||
|
||||
### Per-Unit Key Derivation
|
||||
|
||||
Each aligned unit has its own decryption key derived from the CPS unit key:
|
||||
|
||||
1. **Derive**: AES-128-ECB encrypt the first 16 bytes of the unit (plaintext TP_extra_header) with the unit key.
|
||||
2. **XOR**: XOR the encrypted result with the original 16 bytes to produce the per-unit decryption key.
|
||||
3. **Decrypt**: AES-128-CBC decrypt bytes 16 through 6143 using the per-unit key and the fixed AACS IV.
|
||||
4. **Clear flag**: Clear the encryption indicator bits (`unit[0] &= !0xC0`).
|
||||
|
||||
### Fixed IV
|
||||
|
||||
All AES-CBC operations in AACS use the same fixed initialization vector, defined in the AACS specification.
|
||||
|
||||
### Verification
|
||||
|
||||
After decryption, the library verifies correctness by checking for MPEG-TS sync bytes (0x47) at the expected 192-byte packet boundaries within the unit. Blu-ray transport stream packets are 192 bytes: 4-byte TP_extra_header followed by a 188-byte TS packet.
|
||||
|
||||
|
||||
## Bus Encryption
|
||||
|
||||
### AACS 1.0
|
||||
|
||||
Standard Blu-ray discs do not use bus encryption. Content is read directly from the disc and decrypted using the unit key.
|
||||
|
||||
### AACS 2.0
|
||||
|
||||
UHD 4K discs add a per-sector bus encryption layer. The drive encrypts data as it is read from the disc, and the host must decrypt it before applying AACS content decryption.
|
||||
|
||||
Bus encryption uses a **read_data_key** obtained during the SCSI handshake. For each 2048-byte sector within an aligned unit, bytes 16 through 2047 are AES-128-CBC encrypted with the read_data_key and the fixed AACS IV. The first 16 bytes of each sector remain plaintext.
|
||||
|
||||
The full decryption pipeline for AACS 2.0:
|
||||
|
||||
1. **Bus decrypt**: For each sector, AES-128-CBC decrypt bytes 16..2047 with the read_data_key.
|
||||
2. **Content decrypt**: Standard per-unit key derivation and AES-128-CBC decryption as described above.
|
||||
|
||||
|
||||
## SCSI Handshake
|
||||
|
||||
The AACS SCSI authentication handshake establishes a shared bus key between host and drive, then uses it to securely transfer the Volume ID and read data keys.
|
||||
|
||||
### Protocol Flow
|
||||
|
||||
1. **Invalidate AGIDs**: Send REPORT KEY with format 0x3F for AGIDs 0-3 to clear stale sessions.
|
||||
2. **Allocate AGID**: REPORT KEY format 0x00 returns a fresh Authentication Grant ID.
|
||||
3. **Send host credentials**: SEND KEY format 0x01 transmits the host nonce (20 random bytes) and host certificate (92 bytes).
|
||||
4. **Receive drive credentials**: REPORT KEY format 0x01 returns the drive nonce and drive certificate.
|
||||
5. **Receive drive key**: REPORT KEY format 0x02 returns the drive's ephemeral EC key point and ECDSA signature over `host_nonce || drive_key_point`.
|
||||
6. **Verify drive key**: The signature is verified against the drive's public key (extracted from its certificate). AACS 1.0 certificates are verified against the AACS LA public key.
|
||||
7. **Send host key**: The host generates an ephemeral key pair, signs `drive_nonce || host_key_point` with the host private key, and sends via SEND KEY format 0x02.
|
||||
8. **Compute bus key**: ECDH shared secret = `host_private_key * drive_key_point`. The bus key is the low 128 bits of the shared point's x-coordinate.
|
||||
|
||||
### Post-Authentication Reads
|
||||
|
||||
- **Volume ID**: REPORT DISC STRUCTURE format 0x80. Returns 16-byte VID encrypted with the bus key, plus an AES-CMAC MAC for integrity verification.
|
||||
- **Read Data Keys**: REPORT DISC STRUCTURE format 0x84. Returns the read_data_key and write_data_key, each AES-ECB encrypted with the bus key.
|
||||
|
||||
### Elliptic Curve
|
||||
|
||||
AACS 1.0 uses a custom 160-bit Weierstrass curve (`y^2 = x^3 + ax + b mod p`) with 20-byte field elements. The library implements full EC arithmetic: point addition, doubling, scalar multiplication, modular inverse, ECDSA sign/verify, and ECDH key agreement.
|
||||
|
||||
|
||||
## AACS 2.0 Status
|
||||
|
||||
AACS 2.0 discs are detected via the Content Certificate file (`Content000.cer` or `Content001.cer`). A certificate type byte of 0x01 indicates AACS 2.0.
|
||||
|
||||
AACS 2.0 drives are identified by their drive certificate type (0x11). These drives natively use P-256/SHA-256, but accept AACS 1.0 host certificates for backward compatibility.
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- AACS 2.0 detection: **implemented** (Content Certificate parsing, drive cert type check)
|
||||
- AACS 1.0 handshake with AACS 2.0 drives: **implemented** (backward compatibility mode)
|
||||
- Full P-256 AACS 2.0 handshake: **not yet implemented** (prepared but rarely needed since drives accept AACS 1.0 host certs)
|
||||
- Bus decryption with read_data_key: **implemented**
|
||||
- Content decryption: **implemented** (same as AACS 1.0)
|
||||
|
||||
In practice, AACS 2.0 UHD discs work through the backward-compatible AACS 1.0 handshake path, with the addition of read_data_key bus decryption.
|
||||
|
||||
|
||||
## API Usage
|
||||
|
||||
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
|
||||
|
||||
```rust
|
||||
use libfreemkv::{DriveSession, Disc};
|
||||
use libfreemkv::disc::ScanOptions;
|
||||
use std::path::Path;
|
||||
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
|
||||
// Check encryption state
|
||||
if disc.encrypted {
|
||||
if let Some(ref aacs) = disc.aacs {
|
||||
println!("AACS {}.0", aacs.version);
|
||||
println!("Key source: {}", aacs.key_source.name());
|
||||
println!("Disc hash: {}", aacs.disc_hash);
|
||||
if let Some(mkb_ver) = aacs.mkb_version {
|
||||
println!("MKB version: {}", mkb_ver);
|
||||
}
|
||||
} else {
|
||||
println!("Encrypted but keys not available");
|
||||
}
|
||||
}
|
||||
|
||||
// Read content -- decryption is automatic
|
||||
let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
while let Some(unit) = reader.read_unit().unwrap() {
|
||||
// unit is 6144 bytes of decrypted content
|
||||
}
|
||||
```
|
||||
|
||||
The application never touches keys, never calls decryption functions, and never manages handshakes. All of that is internal to `Disc::scan()` and `ContentReader::read_unit()`.
|
||||
|
||||
### KEYDB Location
|
||||
|
||||
`ScanOptions` controls where the KEYDB is loaded from. If no explicit path is set, the library checks:
|
||||
|
||||
1. `~/.config/aacs/KEYDB.cfg`
|
||||
2. `/etc/aacs/KEYDB.cfg`
|
||||
|
||||
To specify an explicit path:
|
||||
|
||||
```rust
|
||||
let opts = ScanOptions::with_keydb("/path/to/KEYDB.cfg");
|
||||
let disc = Disc::scan(&mut session, &opts).unwrap();
|
||||
```
|
||||
|
||||
### AacsState
|
||||
|
||||
After a successful scan, `disc.aacs` contains an `AacsState` with:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `version` | `u8` | AACS version (1 or 2) |
|
||||
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
||||
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
||||
| `disc_hash` | `String` | SHA-1 of Unit_Key_RO.inf (hex with 0x prefix) |
|
||||
| `key_source` | `KeySource` | How keys were resolved |
|
||||
| `vuk` | `[u8; 16]` | Volume Unique Key |
|
||||
| `unit_keys` | `Vec<(u32, [u8; 16])>` | Decrypted unit keys (CPS unit number, key) |
|
||||
| `read_data_key` | `Option<[u8; 16]>` | AACS 2.0 bus decryption key |
|
||||
| `volume_id` | `[u8; 16]` | Volume ID from SCSI handshake |
|
||||
|
||||
### KeySource
|
||||
|
||||
| Variant | Description |
|
||||
|---------|-------------|
|
||||
| `KeyDb` | VUK found directly in KEYDB by disc hash |
|
||||
| `KeyDbDerived` | Media key + Volume ID from KEYDB, VUK derived |
|
||||
| `ProcessingKey` | MKB + processing keys from KEYDB |
|
||||
| `DeviceKey` | MKB + device keys, subset-difference tree traversal |
|
||||
|
||||
|
||||
## KEYDB.cfg Format Reference
|
||||
|
||||
The KEYDB.cfg file contains all cryptographic material needed for AACS decryption. Lines starting with `;` or `#` are comments.
|
||||
|
||||
### Device Keys
|
||||
|
||||
```
|
||||
| DK | DEVICE_KEY 0x<key> | DEVICE_NODE 0x<node> | KEY_UV 0x<uv> | KEY_U_MASK_SHIFT 0x<shift>
|
||||
```
|
||||
|
||||
- `key`: 16-byte AES device key (hex)
|
||||
- `node`: Device node number in the subset-difference tree (hex)
|
||||
- `uv`: UV value for tree positioning (hex)
|
||||
- `shift`: U mask shift value (hex)
|
||||
|
||||
### Processing Keys
|
||||
|
||||
```
|
||||
| PK | 0x<key>
|
||||
```
|
||||
|
||||
- `key`: 16-byte pre-computed processing key (hex)
|
||||
|
||||
### Host Certificate
|
||||
|
||||
```
|
||||
| HC | HOST_PRIV_KEY 0x<privkey> | HOST_CERT 0x<cert>
|
||||
```
|
||||
|
||||
- `privkey`: 20-byte ECDSA private key (hex)
|
||||
- `cert`: 92-byte AACS host certificate (hex)
|
||||
|
||||
The host certificate is used for SCSI authentication. It contains the host's public key and is signed by the AACS Licensing Administrator.
|
||||
|
||||
### Disc Entries
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
|
||||
```
|
||||
|
||||
- `disc_hash`: 20-byte SHA-1 of Unit_Key_RO.inf (hex)
|
||||
- `title`: Human-readable disc title
|
||||
- `D`: Date tag, followed by release/rip date
|
||||
- `M`: Media key tag, followed by 16-byte media key (hex)
|
||||
- `I`: Disc ID tag, followed by 16-byte Volume ID (hex)
|
||||
- `V`: VUK tag, followed by 16-byte Volume Unique Key (hex)
|
||||
- `U`: Unit keys tag, followed by space-separated `<unit_num>-0x<key>` pairs
|
||||
|
||||
All fields after the title are optional. A minimal entry needs only the disc hash and VUK:
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | V | 0x<vuk>
|
||||
```
|
||||
|
||||
Inline comments are supported with `;`:
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | V | 0x<vuk> ; MKBv77
|
||||
```
|
||||
@@ -0,0 +1,180 @@
|
||||
# libfreemkv Architecture
|
||||
|
||||
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
|
||||
Rust library with no external dependencies at runtime -- profiles are bundled,
|
||||
AACS keys are derived internally, and all SCSI communication is handled in-process.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** AGPL-3.0-only
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
|
||||
format handling live in the library. CLI binaries are thin wrappers that call
|
||||
`DriveSession::open()` and `Disc::scan()`.
|
||||
|
||||
2. **No external files.** 206 drive profiles are compiled into the binary via
|
||||
`include_str!`. No configuration directory, no runtime file lookups for drive
|
||||
support.
|
||||
|
||||
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
|
||||
available. Callers read cleartext sectors without knowing whether the disc
|
||||
was encrypted.
|
||||
|
||||
4. **Structured errors, no English.** Every error has a numeric code (E1000-E7000).
|
||||
The library never formats user-facing messages -- applications do that.
|
||||
|
||||
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
||||
policy level. If a profile exists, the library uses it.
|
||||
|
||||
---
|
||||
|
||||
## Module Map
|
||||
|
||||
```
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive DriveSession — open, identify, unlock, read
|
||||
│ ├── scsi ScsiTransport trait + SG_IO implementation
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, hp)
|
||||
│ ├── profile DriveProfile loading, matching, bundled JSON
|
||||
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
||||
│ └── speed DriveSpeed enum, SET CD SPEED CDB builder
|
||||
│
|
||||
├── Disc Scanning
|
||||
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
||||
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
||||
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
||||
│ ├── clpi CLPI clip info parser — EP map, sector extents
|
||||
│ └── jar BD-J JAR label extraction (audio/subtitle names)
|
||||
│
|
||||
├── Encryption
|
||||
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
|
||||
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
|
||||
│
|
||||
└── error Error enum with numeric codes E1000-E7000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drive Access Flow
|
||||
|
||||
```
|
||||
DriveSession::open("/dev/sr0")
|
||||
│
|
||||
├─ scsi::open() Open /dev/sr0 via SG_IO
|
||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||
├─ profile::find_by_drive_id() Match against 206 bundled profiles
|
||||
├─ Platform::new() Instantiate chipset driver (Mt1959)
|
||||
└─ Platform::unlock() Activate raw disc access mode
|
||||
```
|
||||
|
||||
After open, the session provides:
|
||||
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
|
||||
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
|
||||
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
|
||||
- `status()`, `calibrate()`, `read_config()`, `read_register()`
|
||||
|
||||
---
|
||||
|
||||
## Disc Scanning Flow
|
||||
|
||||
```
|
||||
Disc::scan(&mut session, &ScanOptions)
|
||||
│
|
||||
├─ READ CAPACITY Get disc size in sectors
|
||||
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
|
||||
├─ For each BDMV/PLAYLIST/*.mpls:
|
||||
│ ├─ mpls::parse() Extract play items, STN streams
|
||||
│ └─ For each clip:
|
||||
│ └─ clpi::parse() EP map → sector extents for the clip's time range
|
||||
├─ Detect AACS Check for /AACS directory on disc
|
||||
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
|
||||
```
|
||||
|
||||
The result is a `Disc` with:
|
||||
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
|
||||
- `aacs: Option<AacsState>` -- decryption keys if available
|
||||
- `encrypted: bool` -- whether the disc uses AACS
|
||||
|
||||
---
|
||||
|
||||
## AACS Decryption
|
||||
|
||||
Four key resolution paths, tried in order:
|
||||
|
||||
| Path | Method | Speed |
|
||||
|------|--------|-------|
|
||||
| 1 | VUK lookup by disc hash in KEYDB.cfg | Instant |
|
||||
| 2 | Media Key + Volume ID from KEYDB → derive VUK | Fast |
|
||||
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
|
||||
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
|
||||
|
||||
The AACS handshake (`aacs_handshake`) performs ECDH key agreement over the
|
||||
AACS 1.0 160-bit elliptic curve to obtain:
|
||||
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
||||
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
||||
|
||||
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
|
||||
`ContentReader` handles this transparently.
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
All errors carry a numeric code for programmatic handling. No user-facing text
|
||||
is baked into the library.
|
||||
|
||||
| Range | Category | Examples |
|
||||
|-------|----------|----------|
|
||||
| E1xxx | Device errors | `DeviceNotFound`, `DevicePermission` |
|
||||
| E2xxx | Profile errors | `UnsupportedDrive`, `ProfileNotFound`, `ProfileParse` |
|
||||
| E3xxx | Unlock errors | `UnlockFailed`, `SignatureMismatch`, `NotUnlocked`, `NotCalibrated` |
|
||||
| E4xxx | SCSI errors | `ScsiError`, `ScsiTimeout` |
|
||||
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
|
||||
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
||||
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Transport | Status |
|
||||
|----------|-----------|--------|
|
||||
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
|
||||
| macOS | IOKit SCSI passthrough | Planned |
|
||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
|
||||
|
||||
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
||||
implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
||||
|
||||
---
|
||||
|
||||
## Chipset Support
|
||||
|
||||
| Chipset | Drives | Status |
|
||||
|---------|--------|--------|
|
||||
| MediaTek MT1959 | LG, ASUS, hp | Implemented (206 profiles) |
|
||||
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
|
||||
|
||||
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
|
||||
10 handlers (unlock, config, register, calibrate, keepalive, status, probe,
|
||||
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
|
||||
chipset-specific mode and buffer ID bytes.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Linux builds produce a static library and two binaries (`freemkv-info`,
|
||||
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
|
||||
the library compiles but `scsi::open()` returns a platform-not-supported error
|
||||
until the IOKit/SPTI backends are implemented.
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# CLPI Clip Information Format
|
||||
|
||||
## What is CLPI?
|
||||
|
||||
CLPI (Clip Information) files describe the structure of individual M2TS transport streams on a Blu-ray disc. Each `.clpi` file in `BDMV/CLIPINF/` corresponds to one `.m2ts` file in `BDMV/STREAM/` with the same numeric name (e.g. `00001.clpi` describes `00001.m2ts`).
|
||||
|
||||
The most important data in a CLPI file is the **EP (Entry Point) map**, which provides timestamp-to-sector mapping. This is essential for seeking and for extracting the specific sector ranges that correspond to a playlist's in/out time window.
|
||||
|
||||
## File Structure
|
||||
|
||||
CLPI files use big-endian byte order. The header:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 Magic: "HDMV"
|
||||
4 4 Version: "0200" (BD) or "0300" (UHD BD)
|
||||
8 4 SequenceInfo start offset
|
||||
12 4 ProgramInfo start offset
|
||||
16 4 CPI (Characteristic Point Information) start offset
|
||||
20 4 ClipMark start offset
|
||||
24 4 ExtensionData start offset
|
||||
```
|
||||
|
||||
### ClipInfo Section (offset 40)
|
||||
|
||||
Contains basic clip metadata. The source packet count (total number of 192-byte source packets in the M2TS file) is at offset 56:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
40 4 ClipInfo length
|
||||
44 2 Reserved
|
||||
46 1 Stream type
|
||||
47 1 Application type
|
||||
48 4 Reserved
|
||||
52 4 TS recording rate
|
||||
56 4 Source packet count
|
||||
```
|
||||
|
||||
The source packet count multiplied by 192 gives the total byte size of the M2TS file.
|
||||
|
||||
## EP Map
|
||||
|
||||
The EP map lives inside the CPI section and provides random access into the transport stream. It maps PTS timestamps to Source Packet Numbers (SPN), enabling precise seeking without scanning the stream.
|
||||
|
||||
### CPI Section Layout
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 CPI length
|
||||
4 2 Reserved / CPI type
|
||||
6 ... EP map
|
||||
```
|
||||
|
||||
### EP Map Header
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Reserved
|
||||
1 1 Number of stream PID entries
|
||||
2 ... Stream PID entry headers (one per stream)
|
||||
```
|
||||
|
||||
Each stream PID entry header (14 bytes):
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 Stream PID
|
||||
2 2 Reserved + EP stream type
|
||||
4 2 Number of coarse entries
|
||||
6 4 Number of fine entries (note: 32-bit, can be large)
|
||||
10 4 EP map start offset (relative to EP map start)
|
||||
```
|
||||
|
||||
libfreemkv parses only the first stream (primary video), which is sufficient for sector-level seeking.
|
||||
|
||||
### Two-Level Index
|
||||
|
||||
The EP map uses a two-level structure to compress what would otherwise be a very large lookup table:
|
||||
|
||||
- **Coarse entries**: low-resolution index covering large time/sector ranges
|
||||
- **Fine entries**: high-resolution entries within each coarse range
|
||||
|
||||
Each coarse entry points to a range of fine entries via `ref_to_fine_id`.
|
||||
|
||||
### Coarse Entries (8 bytes each)
|
||||
|
||||
Located immediately after the fine table start offset (4 bytes) in the per-stream EP map:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31:14] ref_to_fine_id (18 bits) -- index of first fine entry in this range
|
||||
[13:0] pts_coarse (14 bits) -- upper bits of PTS
|
||||
```
|
||||
|
||||
Second dword:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31:0] spn_coarse (32 bits) -- upper bits of SPN
|
||||
```
|
||||
|
||||
### Fine Entries (4 bytes each)
|
||||
|
||||
Located at the fine table start offset within the per-stream EP map:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31] is_angle_change_point (1 bit)
|
||||
[30:28] I_end_position_offset (3 bits)
|
||||
[27:17] pts_fine (11 bits) -- lower bits of PTS
|
||||
[16:0] spn_fine (17 bits) -- lower bits of SPN
|
||||
```
|
||||
|
||||
## Reconstructing Full PTS and SPN
|
||||
|
||||
The full timestamp and packet number are assembled by combining the coarse and fine components:
|
||||
|
||||
### Full PTS
|
||||
|
||||
```
|
||||
full_pts = (pts_coarse << 19) + (pts_fine << 8)
|
||||
```
|
||||
|
||||
The coarse component provides the upper bits, shifted left by 19. The fine component provides mid-range bits, shifted left by 8. The resulting PTS is in 45kHz ticks, matching the MPLS timestamp format.
|
||||
|
||||
### Full SPN
|
||||
|
||||
```
|
||||
full_spn = (spn_coarse & 0xFFFE0000) + spn_fine
|
||||
```
|
||||
|
||||
The coarse SPN provides the upper 15 bits (masked to clear the lower 17). The fine SPN provides the lower 17 bits. The result is a Source Packet Number -- each source packet is 192 bytes in the M2TS file.
|
||||
|
||||
### Resolved EP Map
|
||||
|
||||
The `resolved_ep_map()` method iterates through all coarse entries and their associated fine entries to produce a flat list of `(PTS, SPN)` pairs. For each coarse entry at index `ci`:
|
||||
|
||||
- Fine entries range from `coarse[ci].ref_to_fine_id` to `coarse[ci+1].ref_to_fine_id` (or end of fine table for the last coarse entry).
|
||||
- Each fine entry is combined with its parent coarse entry to produce one full `(PTS, SPN)` pair.
|
||||
|
||||
## Deriving Sector Extents for Ripping
|
||||
|
||||
Given a playlist's in_time and out_time (from MPLS), the CLPI EP map provides the sector ranges to read from disc. The `get_extents()` method does this in three steps:
|
||||
|
||||
### Step 1: PTS to SPN
|
||||
|
||||
Binary search the resolved EP map for the in_time and out_time:
|
||||
|
||||
- **start_spn**: the SPN at or before in_time (seek backward to the nearest I-frame)
|
||||
- **end_spn**: the SPN at or after out_time (include the full GOP)
|
||||
|
||||
### Step 2: SPN to Byte Offset
|
||||
|
||||
Each Source Packet is 192 bytes (188 bytes MPEG-TS payload + 4 bytes M2TS header):
|
||||
|
||||
```
|
||||
byte_offset = spn * 192
|
||||
```
|
||||
|
||||
### Step 3: Byte Offset to Sector
|
||||
|
||||
M2TS files are stored contiguously on disc. Sectors are 2048 bytes:
|
||||
|
||||
```
|
||||
start_sector = start_byte / 2048
|
||||
end_sector = (end_byte + 2047) / 2048
|
||||
sector_count = end_sector - start_sector
|
||||
```
|
||||
|
||||
The resulting `Extent` contains `start_lba` (relative to the M2TS file's starting sector on disc) and `sector_count`. The caller adds the file's absolute starting LBA from UDF to get disc-absolute sector numbers.
|
||||
|
||||
### Alignment Note
|
||||
|
||||
The 192-byte source packet size and 2048-byte sector size share no common factor beyond 1. A single sector contains roughly 10.67 source packets. The conversion rounds start down and end up to ensure complete coverage.
|
||||
|
||||
## Putting It Together
|
||||
|
||||
The full ripping pipeline chains three parsers:
|
||||
|
||||
1. **MPLS** provides clip IDs and in/out timestamps.
|
||||
2. **CLPI** converts those timestamps to SPN ranges, then to sector extents.
|
||||
3. **UDF** provides the file's starting LBA on disc for absolute sector addressing.
|
||||
|
||||
The `Disc::scan()` method in `src/disc.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list.
|
||||
|
||||
## References
|
||||
|
||||
- BD-ROM Part 3, Section 5.5: Clip Information file format
|
||||
- https://github.com/lw/BluRay/wiki/CLPI
|
||||
@@ -0,0 +1,114 @@
|
||||
# Disc to Rip: End-to-End Flow
|
||||
|
||||
How libfreemkv goes from a disc in the drive to decrypted content ready for backup.
|
||||
This is the starting point for understanding the library.
|
||||
|
||||
## The Pipeline
|
||||
|
||||
```
|
||||
Insert disc
|
||||
│
|
||||
▼
|
||||
1. Open drive (drive.rs)
|
||||
│ INQUIRY → identify drive
|
||||
│ Match bundled profile → chipset, unlock parameters
|
||||
│
|
||||
▼
|
||||
2. AACS handshake (aacs_handshake.rs) — optional, separate transport
|
||||
│ Allocate AGID
|
||||
│ Exchange certificates + nonces (ECDH)
|
||||
│ Derive bus key
|
||||
│ Read Volume ID + read_data_key
|
||||
│ (fails gracefully if drive doesn't support AACS for this disc)
|
||||
│
|
||||
▼
|
||||
3. Unlock drive (drive.rs → platform/mt1959.rs)
|
||||
│ Vendor-specific command activates raw read mode
|
||||
│ Required — drive firmware blocks all reads without it
|
||||
│
|
||||
▼
|
||||
4. Read UDF filesystem (udf.rs)
|
||||
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
||||
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
||||
│ Metadata partition → File Set Descriptor → Root directory
|
||||
│ Walk directory tree: BDMV/, AACS/, CERTIFICATE/
|
||||
│ → docs/udf.md
|
||||
│
|
||||
▼
|
||||
5. Read AACS files from disc (aacs.rs)
|
||||
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
|
||||
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
||||
│ MKB via SCSI → for key derivation fallback
|
||||
│
|
||||
▼
|
||||
6. Resolve AACS keys (aacs.rs → resolve_keys)
|
||||
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||
│ Path 2: KEYDB media key + Volume ID → VUK
|
||||
│ Path 3: MKB + processing keys → media key → VUK
|
||||
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
||||
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
||||
│ → docs/aacs.md
|
||||
│
|
||||
▼
|
||||
7. Parse playlists (mpls.rs)
|
||||
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
||||
│ Each play item: clip ID, in/out timestamps
|
||||
│ STN table: video, audio, subtitle streams with codec + language
|
||||
│ → docs/mpls.md
|
||||
│
|
||||
▼
|
||||
8. Parse clip info (clpi.rs)
|
||||
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
|
||||
│ Coarse + fine entries → full PTS and SPN
|
||||
│ SPN → byte offset → sector extents for reading
|
||||
│ → docs/clpi.md
|
||||
│
|
||||
▼
|
||||
9. Parse BD-J labels (jar.rs) — optional
|
||||
│ BDMV/JAR/*.jar → Java class constant pool strings
|
||||
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
|
||||
│
|
||||
▼
|
||||
10. Read + decrypt content (disc.rs → ContentReader)
|
||||
│ For each aligned unit (6144 bytes = 3 sectors):
|
||||
│ Read 3 sectors from disc
|
||||
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
|
||||
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
|
||||
│ Output decrypted content
|
||||
│
|
||||
▼
|
||||
Decrypted m2ts stream → ready for muxing/backup
|
||||
```
|
||||
|
||||
## API Summary
|
||||
|
||||
```rust
|
||||
// Steps 1 + 3 (open + unlock)
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
|
||||
// Steps 2 + 4-9 (AACS + scan)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
|
||||
|
||||
// Step 10 (read + decrypt)
|
||||
let mut reader = disc.open_title(&mut session, 0)?;
|
||||
while let Some(unit) = reader.read_unit()? {
|
||||
output.write_all(&unit)?;
|
||||
}
|
||||
```
|
||||
|
||||
Three lines. Everything else is internal.
|
||||
|
||||
## Module Reference
|
||||
|
||||
| Module | Doc | Purpose |
|
||||
|--------|-----|---------|
|
||||
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
|
||||
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
|
||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
||||
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
|
||||
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
|
||||
| disc.rs | -- | High-level scan + read API |
|
||||
| jar.rs | -- | BD-J audio track labels |
|
||||
| error.rs | -- | Error codes (E1xxx-E7xxx) |
|
||||
@@ -0,0 +1,253 @@
|
||||
# Drive Access and Unlock
|
||||
|
||||
Technical reference for how libfreemkv opens, identifies, unlocks, and reads
|
||||
optical drives.
|
||||
|
||||
---
|
||||
|
||||
## DriveSession
|
||||
|
||||
`DriveSession` is the primary API. It owns the SCSI transport, the matched
|
||||
drive profile, and the chipset-specific platform driver.
|
||||
|
||||
### Opening a Drive
|
||||
|
||||
```rust
|
||||
// Full open: identify → match profile → unlock
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
|
||||
// No-unlock open: identify → match profile only
|
||||
let mut session = DriveSession::open_no_unlock(Path::new("/dev/sr0"))?;
|
||||
|
||||
// Explicit profile (skip auto-detection)
|
||||
let mut session = DriveSession::open_with_profile(Path::new("/dev/sr0"), profile)?;
|
||||
```
|
||||
|
||||
**`open()`** performs the full sequence: open device, send INQUIRY, match
|
||||
profile, instantiate platform driver, and unlock. Unlock failures are silently
|
||||
ignored (unencrypted discs do not need it). After `open()`, both raw sector
|
||||
reads and standard READ(10) work immediately.
|
||||
|
||||
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
|
||||
authentication must happen before unlock. The handshake uses standard SCSI
|
||||
commands that work without raw mode. After authentication completes, the caller
|
||||
can invoke `session.unlock()` manually.
|
||||
|
||||
**`open_with_profile()`** bypasses profile auto-detection. Useful for testing
|
||||
or when a custom profile is loaded from an external source.
|
||||
|
||||
### Session Operations
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `unlock()` | Activate raw disc access mode via platform driver |
|
||||
| `is_unlocked()` | Check if raw mode is active |
|
||||
| `calibrate()` | Build speed lookup table for the current disc |
|
||||
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
|
||||
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
|
||||
| `status()` | Query drive status and feature flags |
|
||||
| `read_config()` | Read drive configuration block (1888 bytes) |
|
||||
| `read_register(index)` | Read 16-byte hardware register |
|
||||
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
|
||||
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
|
||||
|
||||
---
|
||||
|
||||
## SCSI Transport
|
||||
|
||||
### Trait
|
||||
|
||||
```rust
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
```
|
||||
|
||||
All drive communication goes through this trait. The library never opens file
|
||||
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
||||
|
||||
### Linux: SG_IO
|
||||
|
||||
The `SgIoTransport` implementation:
|
||||
|
||||
1. Opens the device path with `O_RDWR | O_NONBLOCK`.
|
||||
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
|
||||
3. Calls `ioctl(fd, SG_IO, &hdr)`.
|
||||
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
|
||||
|
||||
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
||||
sense buffer and returns `Error::ScsiError`.
|
||||
|
||||
### CDB Builders
|
||||
|
||||
The `scsi` module provides platform-agnostic CDB constructors:
|
||||
|
||||
| Function | CDB | Use |
|
||||
|----------|-----|-----|
|
||||
| `inquiry()` | INQUIRY (0x12) | Drive identification |
|
||||
| `get_config_010c()` | GET CONFIGURATION (0x46) | Feature 010C firmware date |
|
||||
| `build_read_buffer()` | READ BUFFER (0x3C) | All platform commands |
|
||||
| `build_set_cd_speed()` | SET CD SPEED (0xBB) | Speed control |
|
||||
| `build_read10_raw()` | READ(10) (0x28) with flag 0x08 | Raw sector reads |
|
||||
|
||||
---
|
||||
|
||||
## Drive Identification
|
||||
|
||||
`DriveId::from_drive()` sends two standard SCSI commands and extracts identity
|
||||
fields:
|
||||
|
||||
| Field | Source | SCSI Reference |
|
||||
|-------|--------|----------------|
|
||||
| `vendor_id` | INQUIRY bytes [8:16] | SPC-4 section 6.4.2 |
|
||||
| `product_id` | INQUIRY bytes [16:32] | SPC-4 section 6.4.2 |
|
||||
| `product_revision` | INQUIRY bytes [32:36] | SPC-4 section 6.4.2 |
|
||||
| `vendor_specific` | INQUIRY bytes [36:43] | SPC-4 section 6.4.2 |
|
||||
| `firmware_date` | GET CONFIGURATION Feature 010C | MMC-6 section 5.3.10 |
|
||||
|
||||
The match key is `"VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC"`. Profile matching
|
||||
tries all four fields first, then falls back to matching without the firmware
|
||||
date for drives where Feature 010C is unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Drive Profiles
|
||||
|
||||
Profiles are JSON objects compiled into the binary (`profiles.json`,
|
||||
206 entries). Each profile contains:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `vendor_id`, `product_revision`, `vendor_specific`, `firmware_date` | Matching fields |
|
||||
| `chipset` | `"mediatek"` or `"renesas"` |
|
||||
| `unlock_mode`, `unlock_buf_id` | READ BUFFER CDB parameters |
|
||||
| `signature` | Expected 4-byte response signature |
|
||||
| `unlock_cdb` | Pre-built unlock CDB (hex-encoded) |
|
||||
| `register_offsets` | Offsets for hardware register reads |
|
||||
| `capabilities` | Feature flags: `bd_raw_read`, `dvd_all_regions`, etc. |
|
||||
|
||||
Loading:
|
||||
|
||||
```rust
|
||||
// Bundled (compiled-in) -- no file I/O
|
||||
let profiles = profile::load_bundled()?;
|
||||
|
||||
// External file
|
||||
let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chipsets
|
||||
|
||||
### MediaTek MT1959
|
||||
|
||||
Covers all LG, ASUS, and hp optical drives. Two sub-variants share identical
|
||||
logic with different SCSI parameters:
|
||||
|
||||
| Variant | READ BUFFER mode | Buffer ID |
|
||||
|---------|------------------|-----------|
|
||||
| MT1959-A | 0x01 | 0x44 |
|
||||
| MT1959-B | 0x02 | 0x77 |
|
||||
|
||||
The Platform trait maps to 10 command handlers:
|
||||
|
||||
| Handler | Function | Description |
|
||||
|---------|----------|-------------|
|
||||
| 0 | `unlock()` | Send READ BUFFER, verify signature + verification bytes |
|
||||
| 1 | `read_config()` | Read 1888-byte configuration block + 4-byte status |
|
||||
| 2-3 | `read_register()` | Read hardware registers at profile-specified offsets |
|
||||
| 4 | `calibrate()` | Probe disc surface, build 64-entry speed table |
|
||||
| 5 | `keepalive()` | Periodic session maintenance |
|
||||
| 6 | `status()` | Query current mode and feature flags |
|
||||
| 7 | `probe()` | Generic READ BUFFER with dynamic parameters |
|
||||
| 8 | `read_sectors()` | Speed lookup + SET CD SPEED + READ(10) with flag 0x08 |
|
||||
| 9 | `timing()` | Timing calibration |
|
||||
|
||||
### Renesas (Planned)
|
||||
|
||||
RS8xxx/RS9xxx chipsets used in Pioneer and some HL-DT-ST drives.
|
||||
Currently returns `Error::UnsupportedDrive` when a Renesas profile is matched.
|
||||
|
||||
---
|
||||
|
||||
## Why Unlock Is Needed
|
||||
|
||||
Optical drive firmware restricts what applications can read from disc. Without
|
||||
unlock:
|
||||
|
||||
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
|
||||
playlists, and CLPI clip info are readable without unlock. The `read_disc()`
|
||||
method uses standard READ(10) and works on any drive.
|
||||
|
||||
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
|
||||
SCSI errors (sense key 0x05, illegal request) when an application attempts to
|
||||
read sectors containing encrypted m2ts content without prior AACS
|
||||
authentication via the bus key.
|
||||
|
||||
- **The kernel sr driver blocks block-device reads.** On Linux, the kernel's
|
||||
SCSI CD-ROM driver (`sr`) refuses to expose encrypted disc content through
|
||||
`/dev/sr0` as a block device. Even if you open the block device directly,
|
||||
reads to encrypted regions fail.
|
||||
|
||||
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
|
||||
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
|
||||
regardless of encryption status. This is how raw sector ripping works.
|
||||
|
||||
### open() vs open_no_unlock()
|
||||
|
||||
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
|
||||
These must execute before unlock because:
|
||||
|
||||
1. The AACS handshake establishes a bus key via ECDH.
|
||||
2. The bus key encrypts the Volume ID and Read Data Key responses.
|
||||
3. The Volume ID is needed to derive the Volume Unique Key (VUK).
|
||||
4. The VUK is needed to decrypt unit keys from `Unit_Key_RO.inf`.
|
||||
|
||||
If `open()` unlocks first, some drives reject the subsequent AACS commands.
|
||||
The correct sequence for encrypted discs is:
|
||||
|
||||
```rust
|
||||
// 1. Open without unlock
|
||||
let mut session = DriveSession::open_no_unlock(device)?;
|
||||
|
||||
// 2. AACS handshake (uses standard SCSI, no unlock needed)
|
||||
let auth = aacs_handshake::aacs_authenticate(&mut session, &key, &cert)?;
|
||||
let vid = aacs_handshake::read_volume_id(&mut session, &mut auth)?;
|
||||
|
||||
// 3. Now unlock for raw reads
|
||||
session.unlock()?;
|
||||
session.calibrate()?;
|
||||
|
||||
// 4. Read and decrypt content
|
||||
session.read_sectors(lba, count, &mut buf)?;
|
||||
```
|
||||
|
||||
In practice, `Disc::scan()` handles this internally. The default `open()` call
|
||||
unlocks immediately and is correct for most use cases -- the scan re-opens a
|
||||
second session with `open_no_unlock()` for the AACS handshake when needed.
|
||||
|
||||
---
|
||||
|
||||
## Speed Control
|
||||
|
||||
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
|
||||
built by probing the disc surface. On each `read_sectors()` call, the driver:
|
||||
|
||||
1. Looks up the optimal speed for the target LBA in the table.
|
||||
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
|
||||
3. Performs the READ(10).
|
||||
|
||||
Available speeds:
|
||||
|
||||
| Format | Speeds |
|
||||
|--------|--------|
|
||||
| Blu-ray | 1x (4,500 KB/s) through 12x (54,000 KB/s) |
|
||||
| DVD | 1x (1,385 KB/s) through 16x (22,160 KB/s) |
|
||||
| Max | 0xFFFF (drive decides) |
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# MPLS Playlist Format
|
||||
|
||||
## What is MPLS?
|
||||
|
||||
MPLS (Movie PlayList) files define playback titles on a Blu-ray disc. Each `.mpls` file in `BDMV/PLAYLIST/` describes one title -- a sequence of clips with precise in/out timestamps and stream information. The main movie, bonus features, trailers, and menus each have their own MPLS file.
|
||||
|
||||
A disc may contain dozens of MPLS files. Most are short (menus, logos, transitions). The main movie is typically the longest playlist. libfreemkv filters out playlists shorter than 30 seconds.
|
||||
|
||||
## File Structure
|
||||
|
||||
MPLS files use big-endian byte order throughout. The file has three main sections:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 Magic: "MPLS"
|
||||
4 4 Version: "0200" (BD) or "0300" (UHD BD)
|
||||
8 4 PlayList start offset (absolute from file start)
|
||||
12 4 PlayListMark start offset
|
||||
16 4 ExtensionData start offset
|
||||
```
|
||||
|
||||
### PlayList Section
|
||||
|
||||
Located at the PlayList start offset. Contains all play items and sub-path entries:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 PlayList length
|
||||
4 2 Reserved
|
||||
6 2 Number of PlayItems
|
||||
8 2 Number of SubPaths
|
||||
10 ... PlayItem entries (variable length)
|
||||
```
|
||||
|
||||
### Play Items
|
||||
|
||||
Each play item references one clip and specifies what portion to play:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 PlayItem length (bytes after this field)
|
||||
2 5 Clip ID (ASCII, e.g. "00001")
|
||||
7 4 Codec ID ("M2TS")
|
||||
11 1 Connection condition (lower 4 bits)
|
||||
12 1 Ref to STC_id
|
||||
14 4 IN_time (45kHz PTS ticks)
|
||||
18 4 OUT_time (45kHz PTS ticks)
|
||||
22 8 UO_mask_table
|
||||
30 1 Misc flags
|
||||
31 1 still_mode
|
||||
32 ... STN_table (first play item only is parsed)
|
||||
```
|
||||
|
||||
**Connection condition** values:
|
||||
- `1` = seamless connection (no gap between clips)
|
||||
- `5`, `6` = non-seamless connection
|
||||
|
||||
**Timestamps** use 45kHz PTS (Presentation Time Stamp) ticks, the same timebase as MPEG transport streams. Duration of a play item = `OUT_time - IN_time`. To convert to seconds: divide by 45000.
|
||||
|
||||
A playlist's total duration is the sum of all play item durations.
|
||||
|
||||
### Clip ID Mapping
|
||||
|
||||
The Clip ID (e.g. "00001") maps to:
|
||||
- `BDMV/STREAM/00001.m2ts` -- the transport stream
|
||||
- `BDMV/CLIPINF/00001.clpi` -- the clip info (EP map, stream details)
|
||||
|
||||
## STN Table
|
||||
|
||||
The Stream Number Table describes all elementary streams available in the clip. It is parsed from the first play item (which defines the title's stream layout).
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 STN table length
|
||||
2 2 Reserved
|
||||
4 1 Number of primary video streams
|
||||
5 1 Number of primary audio streams
|
||||
6 1 Number of PG (subtitle) streams
|
||||
7 1 Number of IG (interactive graphics) streams
|
||||
8 ... Stream entries
|
||||
```
|
||||
|
||||
### Stream Entries
|
||||
|
||||
Each stream entry has two parts: a stream reference and a stream attributes block.
|
||||
|
||||
**Stream reference:**
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Entry length
|
||||
1 1 Stream type (1=PlayItem, 2=SubPath, 3=InMux)
|
||||
2 2 PID (MPEG-TS packet ID, big-endian)
|
||||
```
|
||||
|
||||
**Stream attributes** (immediately follows the reference):
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Attributes length
|
||||
1 1 Coding type
|
||||
2+ ... Type-specific fields
|
||||
```
|
||||
|
||||
The layout of type-specific fields depends on the stream category:
|
||||
|
||||
**Video streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 1 | Format (upper 4 bits) + frame rate (lower 4 bits) |
|
||||
|
||||
**Audio streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 1 | Format (upper 4 bits) + sample rate (lower 4 bits) |
|
||||
| 3 | 3 | Language code (ISO 639-2, e.g. "eng") |
|
||||
|
||||
**PG subtitle and IG streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 3 | Language code |
|
||||
|
||||
## Coding Types
|
||||
|
||||
The coding type byte identifies the codec:
|
||||
|
||||
| Value | Codec | Category |
|
||||
|-------|-------|----------|
|
||||
| `0x02` | MPEG-2 | Video |
|
||||
| `0x1B` | H.264 / AVC | Video |
|
||||
| `0x24` | HEVC / H.265 | Video |
|
||||
| `0xEA` | VC-1 | Video |
|
||||
| `0x80` | LPCM | Audio |
|
||||
| `0x81` | AC-3 (Dolby Digital) | Audio |
|
||||
| `0x82` | DTS | Audio |
|
||||
| `0x83` | TrueHD (Dolby TrueHD) | Audio |
|
||||
| `0x84` | AC-3 Plus (E-AC-3) | Audio |
|
||||
| `0x85` | DTS-HD HR | Audio |
|
||||
| `0x86` | DTS-HD MA | Audio |
|
||||
| `0xA1` | AC-3 Plus (secondary) | Audio |
|
||||
| `0xA2` | DTS-HD HR (secondary) | Audio |
|
||||
| `0x90` | PGS (Presentation Graphics) | Subtitle |
|
||||
| `0x91` | PGS (Interactive Graphics) | Subtitle |
|
||||
|
||||
## Video Format Codes
|
||||
|
||||
| Value | Resolution |
|
||||
|-------|-----------|
|
||||
| 1 | 480i |
|
||||
| 2 | 576i |
|
||||
| 3 | 480p |
|
||||
| 4 | 1080i |
|
||||
| 5 | 720p |
|
||||
| 6 | 1080p |
|
||||
| 7 | 576p |
|
||||
| 8 | 2160p |
|
||||
|
||||
## Video Frame Rate Codes
|
||||
|
||||
| Value | Frame Rate |
|
||||
|-------|-----------|
|
||||
| 1 | 23.976 fps |
|
||||
| 2 | 24 fps |
|
||||
| 3 | 25 fps |
|
||||
| 4 | 29.97 fps |
|
||||
| 6 | 50 fps |
|
||||
| 7 | 59.94 fps |
|
||||
|
||||
## Audio Format Codes
|
||||
|
||||
| Value | Channels |
|
||||
|-------|----------|
|
||||
| 1 | Mono |
|
||||
| 3 | Stereo |
|
||||
| 6 | 5.1 surround |
|
||||
| 12 | 7.1 surround |
|
||||
|
||||
## Audio Sample Rate Codes
|
||||
|
||||
| Value | Rate |
|
||||
|-------|------|
|
||||
| 1 | 48 kHz |
|
||||
| 4 | 96 kHz |
|
||||
| 5 | 192 kHz |
|
||||
| 12 | 48/192 kHz (combo) |
|
||||
| 14 | 48/96 kHz (combo) |
|
||||
|
||||
## How Playlists Map to Titles
|
||||
|
||||
libfreemkv's `Disc::scan()` reads every MPLS file from the disc and builds a `Title` for each:
|
||||
|
||||
1. Read all `.mpls` files from `BDMV/PLAYLIST/` via UDF.
|
||||
2. Parse each with `mpls::parse()`.
|
||||
3. Calculate duration by summing `(OUT_time - IN_time)` across all play items.
|
||||
4. Discard playlists shorter than 30 seconds.
|
||||
5. For each play item, load the corresponding CLPI file to get EP map data and compute sector extents (see [clpi.md](clpi.md)).
|
||||
6. Extract stream info from the STN table of the first play item.
|
||||
7. Sort titles by duration, longest first.
|
||||
|
||||
The resulting `Title` struct contains everything needed to rip: streams, duration, byte size, and the sector extents to read from disc.
|
||||
|
||||
## References
|
||||
|
||||
- BD-ROM Part 3, Section 5.3: PlayList file format
|
||||
- https://github.com/lw/BluRay/wiki/MPLS
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# UDF 2.50 Filesystem Parser
|
||||
|
||||
## What is UDF?
|
||||
|
||||
UDF (Universal Disc Format) is the filesystem standard for optical media. It is defined by ECMA-167 with extensions from the OSTA (Optical Storage Technology Association). BD-ROM discs use UDF revision 2.50, which introduces the **metadata partition** -- a critical feature that separates file metadata from file content on disc.
|
||||
|
||||
## Why UDF 2.50 for Blu-ray?
|
||||
|
||||
Older UDF revisions (1.02, 1.50) scatter ICBs (file metadata) and file data across the same partition. On a high-capacity Blu-ray disc (25-100 GB), this creates excessive seeking when the drive needs to read a directory listing or locate a file. UDF 2.50 solves this by placing all metadata into a contiguous region near the beginning of the disc. The drive reads metadata from one compact area and streams file data from another -- no interleaved seeks.
|
||||
|
||||
BD-ROM Part 3 of the Blu-ray specification mandates UDF 2.50.
|
||||
|
||||
## Metadata Partitions
|
||||
|
||||
A UDF 2.50 BD-ROM has two logical partitions:
|
||||
|
||||
- **Partition 0 (Type 1)** -- the physical partition. Contains actual file data (m2ts streams, playlist files, etc.). Mapped directly to disc sectors starting at the Partition Descriptor's `partitionStartingLocation`.
|
||||
|
||||
- **Partition 1 (Type 2)** -- the metadata partition. Contains all ICBs (Inode-like structures), directory data, and the File Set Descriptor. The metadata partition is itself stored as a file within the physical partition. Its location is found by reading an Extended File Entry at LBA 0 of the physical partition.
|
||||
|
||||
The key rule: **ICBs and directory data live in the metadata partition. File content lives in the physical partition.** When an ICB's allocation descriptor gives an LBA, the partition it refers to depends on what the LBA describes -- metadata-relative for directory entries, physical-partition-relative for file data extents.
|
||||
|
||||
## Pointer Chain
|
||||
|
||||
Reading a UDF 2.50 filesystem follows a fixed chain of pointers. Each step reads one or two 2048-byte sectors:
|
||||
|
||||
```
|
||||
Sector 256: AVDP (Anchor Volume Descriptor Pointer, tag 2)
|
||||
|
|
||||
v
|
||||
Sectors 32-63: VDS (Volume Descriptor Sequence)
|
||||
|-- Partition Descriptor (tag 5) --> partition_start (physical sector)
|
||||
|-- Logical Volume Descriptor (tag 6) --> partition maps, FSD location
|
||||
|
|
||||
v
|
||||
Partition Maps in LVD (offset 440):
|
||||
|-- Map 0: Type 1 (physical partition)
|
||||
|-- Map 1: Type 2 (metadata partition, identified by "*UDF Metadata Partition")
|
||||
|
|
||||
v
|
||||
Metadata file ICB at partition_start + 0 (Extended File Entry, tag 266)
|
||||
|-- Allocation descriptor --> metadata content location
|
||||
|
|
||||
v
|
||||
metadata_start = partition_start + allocation_position
|
||||
|
|
||||
v
|
||||
FSD at metadata_start + 0 (File Set Descriptor, tag 256)
|
||||
|-- Root Directory ICB: long_ad at offset 400 --> root_lba (metadata-relative)
|
||||
|
|
||||
v
|
||||
Root Directory ICB at metadata_start + root_lba (Extended File Entry, tag 266)
|
||||
|-- Allocation descriptor --> directory data location (metadata-relative)
|
||||
|
|
||||
v
|
||||
Directory data: File Identifier Descriptors (tag 257)
|
||||
|-- Each FID names a file/subdirectory and points to its ICB
|
||||
|-- Recurse into subdirectories to build the full file tree
|
||||
```
|
||||
|
||||
### AVDP (Sector 256)
|
||||
|
||||
The Anchor Volume Descriptor Pointer is always at sector 256 (ECMA-167 section 10.2). It points to the Main Volume Descriptor Sequence. Tag identifier = 2.
|
||||
|
||||
### VDS (Sectors 32+)
|
||||
|
||||
The Volume Descriptor Sequence contains:
|
||||
|
||||
- **Partition Descriptor (tag 5)**: byte offset 188 holds `partitionStartingLocation` -- the absolute sector where the physical partition begins.
|
||||
- **Logical Volume Descriptor (tag 6)**: byte offset 268 holds the number of partition maps. The partition maps themselves start at offset 440. For BD-ROM, map 0 is Type 1 (physical) and map 1 is Type 2 (metadata).
|
||||
- **Terminating Descriptor (tag 8)**: signals the end of the VDS.
|
||||
|
||||
### Metadata File
|
||||
|
||||
When two partition maps exist and the second is Type 2, the metadata partition content is located by reading the Extended File Entry at the first sector of the physical partition (partition_start + 0). This ICB's allocation descriptor gives the offset and length of the metadata content within the physical partition.
|
||||
|
||||
### File Set Descriptor
|
||||
|
||||
The FSD (tag 256) sits at metadata-relative LBA 0 (the first sector of the metadata content). It contains a long allocation descriptor at offset 400 pointing to the root directory ICB. The LBA in this long_ad is at bytes 404-407.
|
||||
|
||||
### Directory Traversal
|
||||
|
||||
Each directory is an ICB (Extended File Entry, tag 266, or File Entry, tag 261) whose allocation extent points to directory data. The directory data is a sequence of File Identifier Descriptors (FIDs, tag 257):
|
||||
|
||||
| FID Field | Offset | Size | Description |
|
||||
|-----------|--------|------|-------------|
|
||||
| Tag | 0 | 2 | Always 257 |
|
||||
| File characteristics | 18 | 1 | Bit 1 = directory, bit 3 = parent |
|
||||
| L_FI (name length) | 19 | 1 | Length of filename |
|
||||
| ICB (long_ad) | 20 | 16 | Points to the entry's ICB |
|
||||
| L_IU | 36 | 2 | Implementation use length |
|
||||
| Filename | 38 + L_IU | L_FI | UDF-encoded filename |
|
||||
|
||||
FIDs are 4-byte aligned. The parser advances by `(38 + L_IU + L_FI + 3) & !3` bytes per entry.
|
||||
|
||||
### ICB Layout
|
||||
|
||||
Both File Entry (tag 261) and Extended File Entry (tag 266) share the same info_length field:
|
||||
|
||||
| Field | Tag 261 Offset | Tag 266 Offset |
|
||||
|-------|---------------|---------------|
|
||||
| info_length (u64) | 56 | 56 |
|
||||
| L_EA (u32) | 168 | 208 |
|
||||
| L_AD (u32) | 172 | 212 |
|
||||
| Allocation descriptors | 176 + L_EA | 216 + L_EA |
|
||||
|
||||
Allocation descriptors use the Short Allocation Descriptor format: 4 bytes extent length (upper 2 bits = type), 4 bytes extent position (LBA).
|
||||
|
||||
## How read_filesystem() Works
|
||||
|
||||
The `read_filesystem()` function in `src/udf.rs` follows the pointer chain above:
|
||||
|
||||
1. Reads sector 256, validates AVDP (tag 2).
|
||||
2. Scans sectors 32-63 for the Partition Descriptor and Logical Volume Descriptor.
|
||||
3. If two partition maps exist and the second is Type 2, reads the metadata file ICB at partition_start to find metadata_start.
|
||||
4. Reads the FSD at metadata_start, extracts the root directory ICB LBA.
|
||||
5. Calls `read_directory()` recursively (max depth 3) to build the full file tree.
|
||||
|
||||
Each directory read involves two sector reads: one for the ICB, then one or more for the directory data. File sizes are read from info_length in each file's ICB.
|
||||
|
||||
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
|
||||
|
||||
### UDF Filename Encoding
|
||||
|
||||
UDF filenames use a compression ID as the first byte:
|
||||
- `8` = 8-bit characters (ASCII)
|
||||
- `16` = 16-bit big-endian Unicode (UTF-16BE)
|
||||
|
||||
The parser handles both encodings. All path lookups are case-insensitive.
|
||||
|
||||
## BD-ROM Directory Structure
|
||||
|
||||
A typical Blu-ray disc has this directory layout:
|
||||
|
||||
```
|
||||
/
|
||||
+-- BDMV/
|
||||
| +-- index.bdmv Disc index (title list, first play)
|
||||
| +-- MovieObject.bdmv Movie objects (navigation commands)
|
||||
| +-- PLAYLIST/
|
||||
| | +-- 00000.mpls Main movie playlist
|
||||
| | +-- 00001.mpls Director's commentary
|
||||
| | +-- ...
|
||||
| +-- CLIPINF/
|
||||
| | +-- 00001.clpi Clip info for 00001.m2ts
|
||||
| | +-- 00002.clpi
|
||||
| | +-- ...
|
||||
| +-- STREAM/
|
||||
| | +-- 00001.m2ts Transport stream (video/audio/subtitle data)
|
||||
| | +-- 00002.m2ts
|
||||
| | +-- ...
|
||||
| +-- BACKUP/ Duplicate of index, MovieObject, playlists, clip info
|
||||
|
|
||||
+-- AACS/ AACS encryption data (encrypted discs only)
|
||||
| +-- Unit_Key_RO.inf Unit key file (encrypted)
|
||||
| +-- MKB_RW.inf Media Key Block
|
||||
| +-- Content000.cer Content certificate
|
||||
| +-- DUPLICATE/ Backup copies
|
||||
|
|
||||
+-- CERTIFICATE/ BD+ certificate data (some discs)
|
||||
```
|
||||
|
||||
The parser reads from `BDMV/PLAYLIST/` and `BDMV/CLIPINF/` to discover titles and their sector layouts. The `BDMV/STREAM/` directory contains the actual transport streams but is not parsed by the UDF layer -- stream data is read by LBA directly using extents computed from CLPI EP maps.
|
||||
|
||||
## References
|
||||
|
||||
- ECMA-167: Volume and File Structure of Write-Once and Rewritable Media
|
||||
- UDF 2.50 (OSTA): Universal Disk Format Specification
|
||||
- BD-ROM Part 3: Blu-ray Disc Read-Only Format, File System Specifications
|
||||
@@ -0,0 +1,789 @@
|
||||
//! AACS bus authentication handshake — ECDH key agreement + bus key derivation.
|
||||
//!
|
||||
//! Implements the AACS SCSI authentication protocol to obtain:
|
||||
//! - Volume ID (VID) — needed for VUK derivation
|
||||
//! - Read Data Key — needed for AACS 2.0 (UHD) bus decryption
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. Invalidate AGIDs → allocate fresh AGID
|
||||
//! 2. Send host certificate + nonce
|
||||
//! 3. Receive drive certificate + nonce
|
||||
//! 4. Receive drive key point + signature, verify
|
||||
//! 5. Sign host key point, send
|
||||
//! 6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
|
||||
//! 7. Read VID or Read Data Keys (encrypted with bus key)
|
||||
//!
|
||||
//! Supports:
|
||||
//! - AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
|
||||
//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility
|
||||
//! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed)
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::scsi::DataDirection;
|
||||
use num_bigint::BigUint;
|
||||
use num_traits::{One, Zero};
|
||||
use sha1::{Sha1, Digest};
|
||||
|
||||
/// Execute a SCSI command that reads data from the device.
|
||||
fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Execute a SCSI command that writes data to the device.
|
||||
fn scsi_write(session: &mut DriveSession, cdb: &[u8], data: &[u8]) -> Result<()> {
|
||||
let mut buf = data.to_vec();
|
||||
session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── AACS 1.0 elliptic curve parameters (160-bit) ───────────────────────────
|
||||
|
||||
const EC_P: [u8; 20] = [
|
||||
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
|
||||
0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4, 0x79, 0xA7, 0xD7, 0xDF,
|
||||
];
|
||||
const EC_A: [u8; 20] = [
|
||||
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
|
||||
0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4, 0x79, 0xA7, 0xD7, 0xDC,
|
||||
];
|
||||
#[cfg(test)]
|
||||
const EC_B: [u8; 20] = [
|
||||
0x40, 0x2D, 0xAD, 0x3E, 0xC1, 0xCB, 0xCD, 0x16, 0x52, 0x48,
|
||||
0xD6, 0x8E, 0x12, 0x45, 0xE0, 0xC4, 0xDA, 0xAC, 0xB1, 0xD8,
|
||||
];
|
||||
const EC_N: [u8; 20] = [
|
||||
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
|
||||
0xC4, 0x4F, 0x54, 0x81, 0x7B, 0x2C, 0x7F, 0x5A, 0xB0, 0x17,
|
||||
];
|
||||
const EC_GX: [u8; 20] = [
|
||||
0x2E, 0x64, 0xFC, 0x22, 0x57, 0x83, 0x51, 0xE6, 0xF4, 0xCC,
|
||||
0xA7, 0xEB, 0x81, 0xD0, 0xA4, 0xBD, 0xC5, 0x4C, 0xCE, 0xC6,
|
||||
];
|
||||
const EC_GY: [u8; 20] = [
|
||||
0x09, 0x14, 0xA2, 0x5D, 0xD0, 0x54, 0x42, 0x88, 0x9D, 0xB4,
|
||||
0x55, 0xC7, 0xF2, 0x3C, 0x9A, 0x07, 0x07, 0xF5, 0xCB, 0xB9,
|
||||
];
|
||||
|
||||
// ── AACS LA (Licensing Administrator) public key for cert verification ──────
|
||||
|
||||
const AACS_LA_PUB_X: [u8; 20] = [
|
||||
0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E,
|
||||
0x30, 0xC8, 0x8A, 0x60, 0x42, 0x82, 0x07, 0x61, 0xDF, 0x93,
|
||||
];
|
||||
const AACS_LA_PUB_Y: [u8; 20] = [
|
||||
0x44, 0x87, 0xB5, 0xAC, 0x07, 0x10, 0x8D, 0x10, 0x5B, 0xA5,
|
||||
0xB9, 0xE3, 0x2F, 0x3B, 0xBB, 0xFC, 0x0C, 0x2C, 0xBC, 0xD1,
|
||||
];
|
||||
|
||||
// ── Elliptic curve arithmetic over GF(p) ───────────────────────────────────
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EcPoint {
|
||||
x: BigUint,
|
||||
y: BigUint,
|
||||
infinity: bool,
|
||||
}
|
||||
|
||||
impl EcPoint {
|
||||
fn infinity() -> Self {
|
||||
EcPoint { x: BigUint::zero(), y: BigUint::zero(), infinity: true }
|
||||
}
|
||||
|
||||
fn new(x: BigUint, y: BigUint) -> Self {
|
||||
EcPoint { x, y, infinity: false }
|
||||
}
|
||||
|
||||
fn from_bytes(x_bytes: &[u8], y_bytes: &[u8]) -> Self {
|
||||
EcPoint::new(BigUint::from_bytes_be(x_bytes), BigUint::from_bytes_be(y_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Modular inverse using extended Euclidean algorithm.
|
||||
fn mod_inv(a: &BigUint, m: &BigUint) -> Option<BigUint> {
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::Signed;
|
||||
|
||||
let a = BigInt::from(a.clone());
|
||||
let m = BigInt::from(m.clone());
|
||||
|
||||
let (mut old_r, mut r) = (a, m.clone());
|
||||
let (mut old_s, mut s) = (BigInt::one(), BigInt::zero());
|
||||
|
||||
while !r.is_zero() {
|
||||
let q = &old_r / &r;
|
||||
let temp_r = r.clone();
|
||||
r = old_r - &q * &r;
|
||||
old_r = temp_r;
|
||||
let temp_s = s.clone();
|
||||
s = old_s - &q * &s;
|
||||
old_s = temp_s;
|
||||
}
|
||||
|
||||
if old_r != BigInt::one() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if old_s.is_negative() {
|
||||
old_s += &m;
|
||||
}
|
||||
Some(old_s.to_biguint().unwrap())
|
||||
}
|
||||
|
||||
/// EC point addition on curve y² = x³ + ax + b (mod p).
|
||||
fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
if p1.infinity { return p2.clone(); }
|
||||
if p2.infinity { return p1.clone(); }
|
||||
|
||||
if p1.x == p2.x {
|
||||
if p1.y == p2.y && !p1.y.is_zero() {
|
||||
return ec_double(p1, a, p);
|
||||
}
|
||||
return EcPoint::infinity();
|
||||
}
|
||||
|
||||
// λ = (y2 - y1) / (x2 - x1) mod p
|
||||
let dy = if p2.y >= p1.y {
|
||||
(&p2.y - &p1.y) % p
|
||||
} else {
|
||||
(p - (&p1.y - &p2.y) % p) % p
|
||||
};
|
||||
let dx = if p2.x >= p1.x {
|
||||
(&p2.x - &p1.x) % p
|
||||
} else {
|
||||
(p - (&p1.x - &p2.x) % p) % p
|
||||
};
|
||||
|
||||
let dx_inv = mod_inv(&dx, p).unwrap();
|
||||
let lam = (&dy * &dx_inv) % p;
|
||||
|
||||
// x3 = λ² - x1 - x2 mod p
|
||||
let x3 = {
|
||||
let lam2 = (&lam * &lam) % p;
|
||||
let sum = (&p1.x + &p2.x) % p;
|
||||
if lam2 >= sum {
|
||||
(lam2 - sum) % p
|
||||
} else {
|
||||
(p - (sum - lam2) % p) % p
|
||||
}
|
||||
};
|
||||
|
||||
// y3 = λ(x1 - x3) - y1 mod p
|
||||
let y3 = {
|
||||
let diff = if p1.x >= x3 {
|
||||
(&p1.x - &x3) % p
|
||||
} else {
|
||||
(p - (&x3 - &p1.x) % p) % p
|
||||
};
|
||||
let prod = (&lam * &diff) % p;
|
||||
if prod >= p1.y {
|
||||
(prod - &p1.y) % p
|
||||
} else {
|
||||
(p - (&p1.y - prod) % p) % p
|
||||
}
|
||||
};
|
||||
|
||||
EcPoint::new(x3, y3)
|
||||
}
|
||||
|
||||
/// EC point doubling.
|
||||
fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
if pt.infinity || pt.y.is_zero() {
|
||||
return EcPoint::infinity();
|
||||
}
|
||||
|
||||
// λ = (3x² + a) / (2y) mod p
|
||||
let three = BigUint::from(3u32);
|
||||
let two = BigUint::from(2u32);
|
||||
|
||||
let numerator = (&three * &pt.x * &pt.x + a) % p;
|
||||
let denominator = (&two * &pt.y) % p;
|
||||
let denom_inv = mod_inv(&denominator, p).unwrap();
|
||||
let lam = (&numerator * &denom_inv) % p;
|
||||
|
||||
// x3 = λ² - 2x mod p
|
||||
let x3 = {
|
||||
let lam2 = (&lam * &lam) % p;
|
||||
let two_x = (&two * &pt.x) % p;
|
||||
if lam2 >= two_x {
|
||||
(lam2 - two_x) % p
|
||||
} else {
|
||||
(p - (two_x - lam2) % p) % p
|
||||
}
|
||||
};
|
||||
|
||||
// y3 = λ(x - x3) - y mod p
|
||||
let y3 = {
|
||||
let diff = if pt.x >= x3 {
|
||||
(&pt.x - &x3) % p
|
||||
} else {
|
||||
(p - (&x3 - &pt.x) % p) % p
|
||||
};
|
||||
let prod = (&lam * &diff) % p;
|
||||
if prod >= pt.y {
|
||||
(prod - &pt.y) % p
|
||||
} else {
|
||||
(p - (&pt.y - prod) % p) % p
|
||||
}
|
||||
};
|
||||
|
||||
EcPoint::new(x3, y3)
|
||||
}
|
||||
|
||||
/// Scalar multiplication using double-and-add.
|
||||
fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
if k.is_zero() {
|
||||
return EcPoint::infinity();
|
||||
}
|
||||
|
||||
let mut result = EcPoint::infinity();
|
||||
let mut base = pt.clone();
|
||||
let mut scalar = k.clone();
|
||||
|
||||
while !scalar.is_zero() {
|
||||
if scalar.bit(0) {
|
||||
result = ec_add(&result, &base, a, p);
|
||||
}
|
||||
base = ec_double(&base, a, p);
|
||||
scalar >>= 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Convert BigUint to fixed-size big-endian bytes, zero-padded.
|
||||
fn to_bytes_be_padded(n: &BigUint, len: usize) -> Vec<u8> {
|
||||
let bytes = n.to_bytes_be();
|
||||
if bytes.len() >= len {
|
||||
bytes[bytes.len() - len..].to_vec()
|
||||
} else {
|
||||
let mut padded = vec![0u8; len - bytes.len()];
|
||||
padded.extend_from_slice(&bytes);
|
||||
padded
|
||||
}
|
||||
}
|
||||
|
||||
// ── ECDSA ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// ECDSA sign: sign SHA-1(data) with private key on AACS curve.
|
||||
/// Returns (r, s) each 20 bytes.
|
||||
fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) {
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let n = BigUint::from_bytes_be(&EC_N);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let d = BigUint::from_bytes_be(priv_key);
|
||||
|
||||
// Hash the data
|
||||
let hash = Sha1::digest(data);
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
loop {
|
||||
// Generate random k
|
||||
let mut k_bytes = [0u8; 20];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut k_bytes);
|
||||
let k = BigUint::from_bytes_be(&k_bytes) % &n;
|
||||
if k.is_zero() { continue; }
|
||||
|
||||
// R = k × G
|
||||
let r_point = ec_mul(&k, &g, &a, &p);
|
||||
let r = &r_point.x % &n;
|
||||
if r.is_zero() { continue; }
|
||||
|
||||
// s = k⁻¹(z + r·d) mod n
|
||||
let k_inv = match mod_inv(&k, &n) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
let s = (&k_inv * ((&z + &r * &d) % &n)) % &n;
|
||||
if s.is_zero() { continue; }
|
||||
|
||||
let r_bytes = to_bytes_be_padded(&r, 20);
|
||||
let s_bytes = to_bytes_be_padded(&s, 20);
|
||||
|
||||
let mut r_out = [0u8; 20];
|
||||
let mut s_out = [0u8; 20];
|
||||
r_out.copy_from_slice(&r_bytes);
|
||||
s_out.copy_from_slice(&s_bytes);
|
||||
|
||||
return (r_out, s_out);
|
||||
}
|
||||
}
|
||||
|
||||
/// ECDSA verify: verify signature (r, s) against SHA-1(data) using public key.
|
||||
fn ecdsa_verify(pub_x: &[u8; 20], pub_y: &[u8; 20], sig_r: &[u8; 20], sig_s: &[u8; 20], data: &[u8]) -> bool {
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let n = BigUint::from_bytes_be(&EC_N);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let q = EcPoint::from_bytes(pub_x, pub_y);
|
||||
|
||||
let r = BigUint::from_bytes_be(sig_r);
|
||||
let s = BigUint::from_bytes_be(sig_s);
|
||||
|
||||
if r.is_zero() || r >= n || s.is_zero() || s >= n {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hash = Sha1::digest(data);
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
let s_inv = match mod_inv(&s, &n) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let u1 = (&z * &s_inv) % &n;
|
||||
let u2 = (&r * &s_inv) % &n;
|
||||
|
||||
let p1 = ec_mul(&u1, &g, &a, &p);
|
||||
let p2 = ec_mul(&u2, &q, &a, &p);
|
||||
let r_point = ec_add(&p1, &p2, &a, &p);
|
||||
|
||||
if r_point.infinity {
|
||||
return false;
|
||||
}
|
||||
|
||||
&r_point.x % &n == r
|
||||
}
|
||||
|
||||
// ── AACS certificate handling ───────────────────────────────────────────────
|
||||
|
||||
/// Verify an AACS certificate (92 bytes) against the AACS LA public key.
|
||||
fn verify_cert(cert: &[u8]) -> bool {
|
||||
if cert.len() < 92 { return false; }
|
||||
// Certificate format: type(1) + flags(1) + padding(2) + serial(6) + pub_x(20) + pub_y(20) + sig_r(20) + sig_s(20)
|
||||
// Signature is over the first 52 bytes
|
||||
let mut sig_r = [0u8; 20];
|
||||
let mut sig_s = [0u8; 20];
|
||||
sig_r.copy_from_slice(&cert[52..72]);
|
||||
sig_s.copy_from_slice(&cert[72..92]);
|
||||
|
||||
ecdsa_verify(&AACS_LA_PUB_X, &AACS_LA_PUB_Y, &sig_r, &sig_s, &cert[..52])
|
||||
}
|
||||
|
||||
/// Extract public key from certificate.
|
||||
fn cert_pub_key(cert: &[u8]) -> ([u8; 20], [u8; 20]) {
|
||||
let mut x = [0u8; 20];
|
||||
let mut y = [0u8; 20];
|
||||
x.copy_from_slice(&cert[12..32]);
|
||||
y.copy_from_slice(&cert[32..52]);
|
||||
(x, y)
|
||||
}
|
||||
|
||||
// ── Bus key derivation (ECDH) ───────────────────────────────────────────────
|
||||
|
||||
/// Compute bus key via ECDH: bus_key = low 128 bits of (host_priv × drive_key_point).x
|
||||
fn compute_bus_key(host_priv: &[u8; 20], drive_key_point_x: &[u8; 20], drive_key_point_y: &[u8; 20]) -> [u8; 16] {
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
|
||||
let d = BigUint::from_bytes_be(host_priv);
|
||||
let dkp = EcPoint::from_bytes(drive_key_point_x, drive_key_point_y);
|
||||
|
||||
let shared = ec_mul(&d, &dkp, &a, &p);
|
||||
|
||||
// Bus key = lowest 128 bits (last 16 bytes) of x-coordinate
|
||||
let x_bytes = to_bytes_be_padded(&shared.x, 20);
|
||||
let mut bus_key = [0u8; 16];
|
||||
bus_key.copy_from_slice(&x_bytes[4..20]); // last 16 of 20
|
||||
bus_key
|
||||
}
|
||||
|
||||
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
|
||||
fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
||||
let p_mod = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
|
||||
let mut priv_bytes = [0u8; 20];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut priv_bytes);
|
||||
let d = BigUint::from_bytes_be(&priv_bytes);
|
||||
|
||||
let q = ec_mul(&d, &g, &a, &p_mod);
|
||||
|
||||
let qx = to_bytes_be_padded(&q.x, 20);
|
||||
let qy = to_bytes_be_padded(&q.y, 20);
|
||||
|
||||
let mut pub_x = [0u8; 20];
|
||||
let mut pub_y = [0u8; 20];
|
||||
pub_x.copy_from_slice(&qx);
|
||||
pub_y.copy_from_slice(&qy);
|
||||
|
||||
(priv_bytes, pub_x, pub_y)
|
||||
}
|
||||
|
||||
// ── AES-CMAC (for MAC verification) ────────────────────────────────────────
|
||||
|
||||
/// AES-128-CMAC over 16 bytes of data.
|
||||
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
|
||||
// For single-block CMAC:
|
||||
// 1. Generate subkey K1
|
||||
let mut l = GenericArray::clone_from_slice(&[0u8; 16]);
|
||||
cipher.encrypt_block(&mut l);
|
||||
|
||||
let mut k1 = [0u8; 16];
|
||||
let carry = (l[0] >> 7) & 1;
|
||||
for i in 0..15 {
|
||||
k1[i] = (l[i] << 1) | (l[i + 1] >> 7);
|
||||
}
|
||||
k1[15] = l[15] << 1;
|
||||
if carry == 1 {
|
||||
k1[15] ^= 0x87; // Rb for AES-128
|
||||
}
|
||||
|
||||
// 2. XOR data with K1, encrypt
|
||||
let mut block = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
block[i] = data[i] ^ k1[i];
|
||||
}
|
||||
let mut ga = GenericArray::clone_from_slice(&block);
|
||||
cipher.encrypt_block(&mut ga);
|
||||
|
||||
let mut mac = [0u8; 16];
|
||||
mac.copy_from_slice(&ga);
|
||||
mac
|
||||
}
|
||||
|
||||
// ── SCSI command builders ───────────────────────────────────────────────────
|
||||
|
||||
/// Build REPORT KEY CDB (0xA4).
|
||||
fn cdb_report_key(agid: u8, format: u8, len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[7] = crate::scsi::AACS_KEY_CLASS;
|
||||
cdb[8] = (len >> 8) as u8;
|
||||
cdb[9] = (len & 0xFF) as u8;
|
||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build SEND KEY CDB (0xA3).
|
||||
fn cdb_send_key(agid: u8, format: u8, len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_SEND_KEY;
|
||||
cdb[7] = crate::scsi::AACS_KEY_CLASS;
|
||||
cdb[8] = (len >> 8) as u8;
|
||||
cdb[9] = (len & 0xFF) as u8;
|
||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build REPORT DISC STRUCTURE CDB (0xAD).
|
||||
fn cdb_report_disc_structure(agid: u8, format: u8, len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
|
||||
cdb[1] = 0x01; // Blu-ray
|
||||
cdb[7] = format;
|
||||
cdb[8] = (len >> 8) as u8;
|
||||
cdb[9] = (len & 0xFF) as u8;
|
||||
cdb[10] = agid << 6;
|
||||
cdb
|
||||
}
|
||||
|
||||
// ── High-level handshake ────────────────────────────────────────────────────
|
||||
|
||||
/// Result of a successful AACS authentication handshake.
|
||||
#[derive(Debug)]
|
||||
pub struct AacsAuth {
|
||||
/// Bus key (16 bytes) — derived from ECDH
|
||||
pub bus_key: [u8; 16],
|
||||
/// AGID used for this session
|
||||
pub agid: u8,
|
||||
/// Volume ID (16 bytes) — read after auth
|
||||
pub volume_id: Option<[u8; 16]>,
|
||||
/// Read data key (16 bytes) — for AACS 2.0 bus decryption
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// Drive certificate (92 bytes)
|
||||
pub drive_cert: [u8; 92],
|
||||
}
|
||||
|
||||
/// Perform the full AACS authentication handshake.
|
||||
///
|
||||
/// Requires a host private key (20 bytes) and host certificate (92 bytes)
|
||||
/// from the KEYDB.cfg HC entry.
|
||||
pub fn aacs_authenticate(
|
||||
session: &mut DriveSession,
|
||||
host_priv_key: &[u8; 20],
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
if host_cert.len() < 92 {
|
||||
return Err(Error::AacsError { detail: "host certificate too short".into() });
|
||||
}
|
||||
|
||||
// Step 1: Invalidate all AGIDs
|
||||
for agid in 0..4u8 {
|
||||
let cdb = cdb_report_key(agid, 0x3F, 2);
|
||||
let _ = scsi_read(session, &cdb, 2);
|
||||
}
|
||||
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8)
|
||||
.map_err(|e| Error::AacsError { detail: format!("failed to allocate AGID: {}", e) })?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce and ephemeral key pair
|
||||
let mut host_nonce = [0u8; 20];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut host_nonce);
|
||||
let (host_key, host_key_point_x, host_key_point_y) = generate_host_key_pair();
|
||||
|
||||
// Step 4: Send host certificate + nonce (SEND KEY format 0x01)
|
||||
let mut send_buf = [0u8; 116];
|
||||
send_buf[1] = 0x72; // data length
|
||||
send_buf[4..24].copy_from_slice(&host_nonce);
|
||||
send_buf[24..116].copy_from_slice(&host_cert[..92]);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 116);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected host certificate".into() })?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce (REPORT KEY format 0x01)
|
||||
let cdb = cdb_report_key(agid, 0x01, 116);
|
||||
let response = scsi_read(session, &cdb, 116)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read drive certificate".into() })?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
let mut drive_cert = [0u8; 92];
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
drive_cert.copy_from_slice(&response[24..116]);
|
||||
|
||||
// Detect AACS 2.0 drive certificate (type 0x11)
|
||||
// AACS 2.0 drives use P-256/SHA-256 natively but accept AACS 1.0 host certs
|
||||
// for backward compatibility. We proceed with AACS 1.0 handshake.
|
||||
if drive_cert[0] == 0x11 {
|
||||
// AACS 2.0 drive detected — falling back to AACS 1.0 handshake
|
||||
// (full P-256 AACS 2.0 handshake not yet implemented)
|
||||
// The drive should still accept our AACS 1.0 host certificate.
|
||||
}
|
||||
|
||||
// Verify drive certificate (AACS 1.0 LA signature)
|
||||
if drive_cert[0] == 0x01 && !verify_cert(&drive_cert) {
|
||||
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
|
||||
}
|
||||
// Skip verification for AACS 2.0 certs (different LA key, P-256 curve)
|
||||
|
||||
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
|
||||
let cdb = cdb_report_key(agid, 0x02, 84);
|
||||
let response = scsi_read(session, &cdb, 84)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read drive key".into() })?;
|
||||
|
||||
let mut drive_key_point = [0u8; 40]; // x(20) + y(20)
|
||||
let mut drive_key_sig = [0u8; 40]; // r(20) + s(20)
|
||||
drive_key_point.copy_from_slice(&response[4..44]);
|
||||
drive_key_sig.copy_from_slice(&response[44..84]);
|
||||
|
||||
// Verify drive key signature: sign(drive_nonce=host_nonce || drive_key_point)
|
||||
let (drive_pub_x, drive_pub_y) = cert_pub_key(&drive_cert);
|
||||
let mut verify_data = [0u8; 60];
|
||||
verify_data[..20].copy_from_slice(&host_nonce);
|
||||
verify_data[20..60].copy_from_slice(&drive_key_point);
|
||||
|
||||
let mut sig_r = [0u8; 20];
|
||||
let mut sig_s = [0u8; 20];
|
||||
sig_r.copy_from_slice(&drive_key_sig[..20]);
|
||||
sig_s.copy_from_slice(&drive_key_sig[20..40]);
|
||||
|
||||
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
|
||||
return Err(Error::AacsError { detail: "drive key signature verification failed".into() });
|
||||
}
|
||||
|
||||
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
|
||||
let mut sign_data = [0u8; 60];
|
||||
sign_data[..20].copy_from_slice(&drive_nonce);
|
||||
sign_data[20..40].copy_from_slice(&host_key_point_x);
|
||||
sign_data[40..60].copy_from_slice(&host_key_point_y);
|
||||
|
||||
let (host_sig_r, host_sig_s) = ecdsa_sign(host_priv_key, &sign_data);
|
||||
|
||||
// Step 8: Send host key point + signature (SEND KEY format 0x02)
|
||||
let mut send_buf = [0u8; 84];
|
||||
send_buf[1] = 0x52;
|
||||
send_buf[4..24].copy_from_slice(&host_key_point_x);
|
||||
send_buf[24..44].copy_from_slice(&host_key_point_y);
|
||||
send_buf[44..64].copy_from_slice(&host_sig_r);
|
||||
send_buf[64..84].copy_from_slice(&host_sig_s);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 84);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected host key".into() })?;
|
||||
|
||||
// Step 9: Compute bus key via ECDH
|
||||
let mut dkp_x = [0u8; 20];
|
||||
let mut dkp_y = [0u8; 20];
|
||||
dkp_x.copy_from_slice(&drive_key_point[..20]);
|
||||
dkp_y.copy_from_slice(&drive_key_point[20..40]);
|
||||
|
||||
let bus_key = compute_bus_key(&host_key, &dkp_x, &dkp_y);
|
||||
|
||||
Ok(AacsAuth {
|
||||
bus_key,
|
||||
agid,
|
||||
volume_id: None,
|
||||
read_data_key: None,
|
||||
drive_cert,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read Volume ID after successful authentication.
|
||||
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
|
||||
// REPORT DISC STRUCTURE format 0x80
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
|
||||
let response = scsi_read(session, &cdb, 36)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read Volume ID".into() })?;
|
||||
|
||||
let mut vid = [0u8; 16];
|
||||
let mut mac = [0u8; 16];
|
||||
vid.copy_from_slice(&response[4..20]);
|
||||
mac.copy_from_slice(&response[20..36]);
|
||||
|
||||
// Verify MAC: AES-CMAC(VID, bus_key) should equal mac
|
||||
let calc_mac = aes_cmac_16(&vid, &auth.bus_key);
|
||||
if calc_mac != mac {
|
||||
return Err(Error::AacsError { detail: "VID MAC verification failed".into() });
|
||||
}
|
||||
|
||||
auth.volume_id = Some(vid);
|
||||
Ok(vid)
|
||||
}
|
||||
|
||||
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
|
||||
pub fn read_data_keys(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> {
|
||||
// REPORT DISC STRUCTURE format 0x84
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
|
||||
let response = scsi_read(session, &cdb, 36)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read data keys".into() })?;
|
||||
|
||||
let mut enc_rdk = [0u8; 16];
|
||||
let mut enc_wdk = [0u8; 16];
|
||||
enc_rdk.copy_from_slice(&response[4..20]);
|
||||
enc_wdk.copy_from_slice(&response[20..36]);
|
||||
|
||||
// Decrypt with bus key (AES-ECB)
|
||||
let read_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_rdk);
|
||||
let write_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_wdk);
|
||||
|
||||
auth.read_data_key = Some(read_data_key);
|
||||
Ok((read_data_key, write_data_key))
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ec_curve_generator_on_curve() {
|
||||
// Verify G is on the curve: y² = x³ + ax + b (mod p)
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let b = BigUint::from_bytes_be(&EC_B);
|
||||
let gx = BigUint::from_bytes_be(&EC_GX);
|
||||
let gy = BigUint::from_bytes_be(&EC_GY);
|
||||
|
||||
let lhs = (&gy * &gy) % &p;
|
||||
let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p;
|
||||
assert_eq!(lhs, rhs, "Generator point is not on the curve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ec_mul_identity() {
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
|
||||
// 1 × G = G
|
||||
let result = ec_mul(&BigUint::one(), &g, &a, &p);
|
||||
assert_eq!(result.x, g.x);
|
||||
assert_eq!(result.y, g.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ec_mul_order() {
|
||||
// n × G = O (point at infinity)
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let n = BigUint::from_bytes_be(&EC_N);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
|
||||
let result = ec_mul(&n, &g, &a, &p);
|
||||
assert!(result.infinity, "n × G should be point at infinity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ecdsa_sign_verify() {
|
||||
// Generate a key pair and test sign/verify
|
||||
let (priv_key, pub_x, pub_y) = generate_host_key_pair();
|
||||
let data = b"test data for AACS ECDSA";
|
||||
|
||||
let (sig_r, sig_s) = ecdsa_sign(&priv_key, data);
|
||||
assert!(ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, data),
|
||||
"ECDSA signature should verify");
|
||||
|
||||
// Verify with wrong data fails
|
||||
assert!(!ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong data"),
|
||||
"ECDSA should fail with wrong data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ecdh_shared_secret() {
|
||||
// Two parties should derive the same shared point
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
|
||||
let (priv_a, pub_ax, pub_ay) = generate_host_key_pair();
|
||||
let (priv_b, pub_bx, pub_by) = generate_host_key_pair();
|
||||
|
||||
// A computes: priv_a × pub_B
|
||||
let shared_a = compute_bus_key(&priv_a, &pub_bx, &pub_by);
|
||||
// B computes: priv_b × pub_A
|
||||
let shared_b = compute_bus_key(&priv_b, &pub_ax, &pub_ay);
|
||||
|
||||
assert_eq!(shared_a, shared_b, "ECDH shared secrets should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_cmac() {
|
||||
// Basic CMAC test — at minimum verify it produces consistent output
|
||||
let key = [0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
|
||||
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c];
|
||||
let data = [0u8; 16];
|
||||
let mac1 = aes_cmac_16(&data, &key);
|
||||
let mac2 = aes_cmac_16(&data, &key);
|
||||
assert_eq!(mac1, mac2);
|
||||
assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_host_cert_from_keydb() {
|
||||
// Verify the host cert from our KEYDB
|
||||
let keydb_path = match std::env::var("KEYDB_PATH").ok() {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => return, // skip if KEYDB_PATH not set
|
||||
};
|
||||
if !keydb_path.exists() { return; }
|
||||
|
||||
let db = crate::aacs::KeyDb::load(&keydb_path).unwrap();
|
||||
if let Some(hc) = &db.host_cert {
|
||||
let valid = verify_cert(&hc.certificate);
|
||||
eprintln!("Host cert verification: {}", if valid { "PASS" } else { "FAIL" });
|
||||
// Note: our cert is revoked but should still have valid LA signature
|
||||
// If it doesn't verify, the LA public key might be wrong
|
||||
if !valid {
|
||||
eprintln!(" (cert may use different LA key or format)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1447
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
//! freemkv-info — Drive identification and compatibility checker.
|
||||
//!
|
||||
//! Sends standard SCSI INQUIRY and GET CONFIGURATION commands to an optical drive,
|
||||
//! displays drive identity and compatibility status, and optionally outputs raw
|
||||
//! response data for profile contribution.
|
||||
//!
|
||||
//! Usage:
|
||||
//! freemkv-info /dev/sr0
|
||||
//! freemkv-info /dev/sr0 --raw
|
||||
//! freemkv-info /dev/sr0 --json
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("freemkv-info — Drive identification and compatibility checker");
|
||||
eprintln!();
|
||||
eprintln!("Usage: freemkv-info <device> [options]");
|
||||
eprintln!();
|
||||
eprintln!(" <device> Optical drive device (e.g. /dev/sr0)");
|
||||
eprintln!(" --raw Output raw SCSI response hex (for profile contribution)");
|
||||
eprintln!(" --json Output machine-readable JSON");
|
||||
eprintln!(" --profiles Path to profiles directory (default: ./profiles)");
|
||||
eprintln!();
|
||||
eprintln!("Examples:");
|
||||
eprintln!(" freemkv-info /dev/sr0");
|
||||
eprintln!(" freemkv-info /dev/sr0 --raw > my_drive.txt");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let device = Path::new(&args[1]);
|
||||
let raw_mode = args.iter().any(|a| a == "--raw");
|
||||
let json_mode = args.iter().any(|a| a == "--json");
|
||||
let profiles_dir = args.iter()
|
||||
.position(|a| a == "--profiles")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("profiles");
|
||||
|
||||
// Open SCSI transport
|
||||
let mut transport = match libfreemkv::scsi::open(device) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("Error: Cannot open {}: {}", device.display(), e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// INQUIRY
|
||||
let inquiry = match libfreemkv::scsi::inquiry(transport.as_mut()) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("Error: INQUIRY failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// GET CONFIGURATION feature 0x010C
|
||||
let gc_010c = libfreemkv::scsi::get_config_010c(transport.as_mut()).ok();
|
||||
|
||||
if json_mode {
|
||||
print_json(&inquiry, &gc_010c);
|
||||
} else if raw_mode {
|
||||
print_raw(&inquiry, &gc_010c);
|
||||
} else {
|
||||
print_human(&inquiry, &gc_010c, profiles_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_human(
|
||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
||||
gc_010c: &Option<Vec<u8>>,
|
||||
profiles_dir: &str,
|
||||
) {
|
||||
println!("freemkv-info v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!();
|
||||
println!("Drive: {} {} {}", inquiry.vendor_id, inquiry.model, inquiry.firmware);
|
||||
println!("INQUIRY: additional_length=0x{:02X} ({})",
|
||||
inquiry.raw.get(4).unwrap_or(&0),
|
||||
inquiry.raw.get(4).unwrap_or(&0));
|
||||
|
||||
if let Some(gc) = gc_010c {
|
||||
let data_hex: String = gc.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
println!("Feature 0x010C: {}", data_hex);
|
||||
} else {
|
||||
println!("Feature 0x010C: not available");
|
||||
}
|
||||
|
||||
// Try to match profile
|
||||
if let Ok(profiles) = libfreemkv::profile::load_all(Path::new(profiles_dir)) {
|
||||
let matched = profiles.iter().find(|p| {
|
||||
p.drive_id.contains(&inquiry.vendor_id)
|
||||
&& p.drive_id.contains(&inquiry.model)
|
||||
});
|
||||
|
||||
println!();
|
||||
match matched {
|
||||
Some(p) => {
|
||||
println!("Profile: FOUND ({})", p.chipset.name());
|
||||
println!("Raw Read: Supported");
|
||||
}
|
||||
None => {
|
||||
println!("Profile: NOT FOUND");
|
||||
println!("Raw Read: Unknown — run with --raw and submit a profile request");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!();
|
||||
println!("Profile: No profiles directory found at '{}'", profiles_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_raw(
|
||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
||||
gc_010c: &Option<Vec<u8>>,
|
||||
) {
|
||||
println!("# freemkv-info raw output");
|
||||
println!("# Submit this file to https://github.com/freemkv/libfreemkv/issues");
|
||||
println!();
|
||||
println!("vendor: {}", inquiry.vendor_id);
|
||||
println!("model: {}", inquiry.model);
|
||||
println!("firmware: {}", inquiry.firmware);
|
||||
println!();
|
||||
|
||||
// Full INQUIRY hex
|
||||
println!("inquiry_hex: {}", hex_encode(&inquiry.raw));
|
||||
println!("inquiry_length: {}", inquiry.raw.len());
|
||||
|
||||
// GET CONFIG 0x010C
|
||||
if let Some(gc) = gc_010c {
|
||||
println!("get_config_010c_hex: {}", hex_encode(gc));
|
||||
println!("get_config_010c_length: {}", gc.len());
|
||||
} else {
|
||||
println!("get_config_010c_hex: ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(
|
||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
||||
gc_010c: &Option<Vec<u8>>,
|
||||
) {
|
||||
let json = serde_json::json!({
|
||||
"vendor": inquiry.vendor_id,
|
||||
"model": inquiry.model,
|
||||
"firmware": inquiry.firmware,
|
||||
"inquiry_hex": hex_encode(&inquiry.raw),
|
||||
"inquiry_length": inquiry.raw.len(),
|
||||
"get_config_010c_hex": gc_010c.as_ref().map(|g| hex_encode(g)),
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&json).unwrap());
|
||||
}
|
||||
|
||||
fn hex_encode(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//! freemkv-test — Quick verification that raw disc access works.
|
||||
//!
|
||||
//! Enables raw read mode, calibrates speed, reads a few test sectors.
|
||||
//! Use this to verify your drive and profile are working correctly.
|
||||
//!
|
||||
//! Usage:
|
||||
//! freemkv-test /dev/sr0
|
||||
//! freemkv-test /dev/sr0 --profiles ./profiles
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("freemkv-test — Verify raw disc access works");
|
||||
eprintln!();
|
||||
eprintln!("Usage: freemkv-test <device> [--profiles <dir>]");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let device = Path::new(&args[1]);
|
||||
|
||||
println!("freemkv-test v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!();
|
||||
|
||||
// Open drive session (uses bundled profiles)
|
||||
print!("Opening {}... ", device.display());
|
||||
let mut session = match libfreemkv::DriveSession::open(device) {
|
||||
Ok(s) => { println!("OK"); s }
|
||||
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
|
||||
};
|
||||
|
||||
println!(" Drive ID: {}", session.profile.drive_id);
|
||||
println!(" Chipset: {}", session.profile.chipset.name());
|
||||
println!();
|
||||
|
||||
// Enable raw read mode
|
||||
print!("Unlocking drive... ");
|
||||
match session.unlock() {
|
||||
Ok(()) => println!("OK"),
|
||||
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
|
||||
}
|
||||
|
||||
// Check status
|
||||
print!("Checking status... ");
|
||||
match session.status() {
|
||||
Ok(status) => {
|
||||
if status.unlocked {
|
||||
println!("OK (active)");
|
||||
} else {
|
||||
println!("WARNING: drive reported as locked");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("SKIP ({})", e),
|
||||
}
|
||||
|
||||
// Calibrate speed
|
||||
print!("Calibrating speed... ");
|
||||
match session.calibrate() {
|
||||
Ok(()) => println!("OK"),
|
||||
Err(e) => println!("SKIP ({})", e),
|
||||
}
|
||||
|
||||
// Read test sectors
|
||||
let test_lbas: &[u32] = &[0, 100, 1000, 10000];
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let mut pass = 0;
|
||||
let mut fail = 0;
|
||||
|
||||
for &lba in test_lbas {
|
||||
print!("Reading sector {}... ", lba);
|
||||
match session.read_sectors(lba, 1, &mut buf) {
|
||||
Ok(n) if n == 2048 => {
|
||||
let nonzero = buf.iter().filter(|&&b| b != 0).count();
|
||||
println!("OK ({} bytes, {} non-zero)", n, nonzero);
|
||||
pass += 1;
|
||||
}
|
||||
Ok(n) => {
|
||||
println!("PARTIAL ({} bytes)", n);
|
||||
fail += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("FAILED: {}", e);
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
if fail == 0 {
|
||||
println!("All {} checks passed. Drive is fully functional.", pass);
|
||||
} else {
|
||||
println!("{} passed, {} failed.", pass, fail);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
+25
-4
@@ -183,11 +183,32 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
|
||||
// stream_PID: 16 bits → ep_map[2..4]
|
||||
// reserved: 10 bits ┐
|
||||
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
|
||||
// num_EP_coarse: 16 bits │ (10+4+16+18+32 = 80)
|
||||
// num_EP_fine: 18 bits │
|
||||
// EP_map_start_address: 32 bits ┘
|
||||
if ep_map.len() < 16 { return Ok((Vec::new(), Vec::new())); }
|
||||
let _stream_pid = u16::from_be_bytes([ep_map[2], ep_map[3]]);
|
||||
// ep_map[4..6] = reserved + EP stream type
|
||||
let num_coarse = u16::from_be_bytes([ep_map[6], ep_map[7]]) as usize;
|
||||
let num_fine = u32::from_be_bytes([ep_map[8], ep_map[9], ep_map[10], ep_map[11]]) as usize;
|
||||
let ep_map_offset = u32::from_be_bytes([ep_map[12], ep_map[13], ep_map[14], ep_map[15]]) as usize;
|
||||
|
||||
// Read 10 bytes (80 bits) from ep_map[4..14] for bit extraction
|
||||
// Use two u64s since we need 80 bits
|
||||
let hi = u64::from_be_bytes([ep_map[4], ep_map[5], ep_map[6], ep_map[7],
|
||||
ep_map[8], ep_map[9], ep_map[10], ep_map[11]]);
|
||||
let lo_bytes = [ep_map[12], ep_map[13]];
|
||||
|
||||
// Bit 0-9: reserved (10)
|
||||
// Bit 10-13: EP_stream_type (4)
|
||||
// Bit 14-29: num_coarse (16)
|
||||
// Bit 30-47: num_fine (18)
|
||||
// Bit 48-79: EP_map_start (32) — bits 48-63 in hi, bits 64-79 in lo
|
||||
let num_coarse = ((hi >> 34) & 0xFFFF) as usize;
|
||||
let num_fine = ((hi >> 16) & 0x3FFFF) as usize;
|
||||
let ep_map_offset = (((hi & 0xFFFF) as u32) << 16)
|
||||
| (u16::from_be_bytes(lo_bytes) as u32);
|
||||
let ep_map_offset = ep_map_offset as usize;
|
||||
|
||||
// EP map for this stream starts at ep_map_offset relative to ep_map start
|
||||
if ep_map_offset + 4 > ep_map.len() {
|
||||
|
||||
+718
-60
@@ -14,15 +14,67 @@ use crate::udf;
|
||||
use crate::mpls;
|
||||
use crate::clpi;
|
||||
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A scanned Blu-ray disc.
|
||||
#[derive(Debug)]
|
||||
pub struct Disc {
|
||||
/// UDF Volume Identifier from Primary Volume Descriptor (always present)
|
||||
pub volume_id: String,
|
||||
/// Disc title from META/DL/bdmt_eng.xml (None if disc has no metadata)
|
||||
pub meta_title: Option<String>,
|
||||
/// Disc format (BD, UHD, DVD)
|
||||
pub format: DiscFormat,
|
||||
/// Disc capacity in sectors
|
||||
pub capacity_sectors: u32,
|
||||
/// Disc capacity in bytes
|
||||
pub capacity_bytes: u64,
|
||||
/// Number of layers (1 = single, 2 = dual)
|
||||
pub layers: u8,
|
||||
/// Titles sorted by duration (longest first), then playlist name
|
||||
pub titles: Vec<Title>,
|
||||
/// Disc region
|
||||
pub region: DiscRegion,
|
||||
/// AACS state — None if disc is unencrypted or keys unavailable
|
||||
pub aacs: Option<AacsState>,
|
||||
/// Whether this disc requires AACS decryption
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// Disc format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DiscFormat {
|
||||
/// 4K UHD Blu-ray (HEVC 2160p)
|
||||
Uhd,
|
||||
/// Standard Blu-ray (1080p/1080i)
|
||||
BluRay,
|
||||
/// DVD
|
||||
Dvd,
|
||||
/// Unknown
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Disc playback region.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DiscRegion {
|
||||
/// Region-free (all UHD discs, some BD/DVD)
|
||||
Free,
|
||||
/// Blu-ray regions (A/B/C or combination)
|
||||
BluRay(Vec<BdRegion>),
|
||||
/// DVD regions (1-8 or combination)
|
||||
Dvd(Vec<u8>),
|
||||
}
|
||||
|
||||
/// Blu-ray region codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BdRegion {
|
||||
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
|
||||
A,
|
||||
/// Region B/2 — Europe, Africa, Australia, Middle East
|
||||
B,
|
||||
/// Region C/3 — Central/South Asia, China, Russia
|
||||
C,
|
||||
}
|
||||
|
||||
/// A title (one MPLS playlist).
|
||||
@@ -36,49 +88,88 @@ pub struct Title {
|
||||
pub duration_secs: f64,
|
||||
/// Total size in bytes
|
||||
pub size_bytes: u64,
|
||||
/// Number of clips
|
||||
pub clip_count: usize,
|
||||
/// Clip references in playback order
|
||||
pub clips: Vec<Clip>,
|
||||
/// All streams (video, audio, subtitle, etc.)
|
||||
pub streams: Vec<Stream>,
|
||||
/// Sector extents for ripping (clip LBA ranges)
|
||||
pub extents: Vec<Extent>,
|
||||
}
|
||||
|
||||
/// A clip reference within a title.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Clip {
|
||||
/// Clip filename without extension (e.g. "00001")
|
||||
pub clip_id: String,
|
||||
/// In-time in 45kHz ticks
|
||||
pub in_time: u32,
|
||||
/// Out-time in 45kHz ticks
|
||||
pub out_time: u32,
|
||||
/// Duration in seconds
|
||||
pub duration_secs: f64,
|
||||
/// Source packet count (from CLPI, 0 if unavailable)
|
||||
pub source_packets: u32,
|
||||
}
|
||||
|
||||
/// A stream within a title.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Stream {
|
||||
/// Stream type
|
||||
pub kind: StreamKind,
|
||||
pub enum Stream {
|
||||
Video(VideoStream),
|
||||
Audio(AudioStream),
|
||||
Subtitle(SubtitleStream),
|
||||
}
|
||||
|
||||
/// A video stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoStream {
|
||||
/// MPEG-TS packet ID
|
||||
pub pid: u16,
|
||||
/// Codec
|
||||
/// Codec (HEVC, H.264, VC-1, MPEG-2)
|
||||
pub codec: Codec,
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Video resolution (e.g. "2160p", "1080p")
|
||||
/// Resolution (e.g. "2160p", "1080p", "1080i")
|
||||
pub resolution: String,
|
||||
/// Frame rate (e.g. "23.976")
|
||||
/// Frame rate (e.g. "23.976", "25")
|
||||
pub frame_rate: String,
|
||||
/// Channel layout (e.g. "5.1", "7.1", "stereo")
|
||||
pub channels: String,
|
||||
/// Sample rate (e.g. "48kHz")
|
||||
pub sample_rate: String,
|
||||
/// HDR format
|
||||
pub hdr: HdrFormat,
|
||||
/// Color space
|
||||
pub color_space: ColorSpace,
|
||||
/// Whether this is a secondary/enhancement stream
|
||||
/// Whether this is a secondary stream (PiP, Dolby Vision EL)
|
||||
pub secondary: bool,
|
||||
/// Extra label (e.g. "Dolby Vision EL")
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Stream type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum StreamKind {
|
||||
Video,
|
||||
Audio,
|
||||
Subtitle,
|
||||
/// An audio stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioStream {
|
||||
/// MPEG-TS packet ID
|
||||
pub pid: u16,
|
||||
/// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
|
||||
pub codec: Codec,
|
||||
/// Channel layout (e.g. "5.1", "7.1", "stereo", "mono")
|
||||
pub channels: String,
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Sample rate (e.g. "48kHz", "96kHz")
|
||||
pub sample_rate: String,
|
||||
/// Whether this is a secondary stream (commentary)
|
||||
pub secondary: bool,
|
||||
/// Extra label
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// A subtitle stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubtitleStream {
|
||||
/// MPEG-TS packet ID
|
||||
pub pid: u16,
|
||||
/// Codec (PGS)
|
||||
pub codec: Codec,
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Whether this is a forced subtitle
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// Video/audio codec.
|
||||
@@ -206,56 +297,623 @@ impl Title {
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// Human-readable one-line description.
|
||||
pub fn display(&self) -> String {
|
||||
match self.kind {
|
||||
StreamKind::Video => {
|
||||
let mut parts = vec![self.codec.name().to_string()];
|
||||
if !self.resolution.is_empty() { parts.push(self.resolution.clone()); }
|
||||
if !self.frame_rate.is_empty() { parts.push(format!("{}fps", self.frame_rate)); }
|
||||
if self.hdr != HdrFormat::Sdr { parts.push(self.hdr.name().to_string()); }
|
||||
if self.color_space != ColorSpace::Unknown && self.color_space != ColorSpace::Bt709 {
|
||||
parts.push(self.color_space.name().to_string());
|
||||
}
|
||||
if self.secondary { parts.push(format!("[{}]", self.label)); }
|
||||
parts.join(" ")
|
||||
}
|
||||
StreamKind::Audio => {
|
||||
let mut parts = vec![self.codec.name().to_string()];
|
||||
if !self.channels.is_empty() { parts.push(self.channels.clone()); }
|
||||
if !self.sample_rate.is_empty() { parts.push(self.sample_rate.clone()); }
|
||||
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
||||
if self.secondary { parts.push("[secondary]".to_string()); }
|
||||
parts.join(" ")
|
||||
}
|
||||
StreamKind::Subtitle => {
|
||||
let mut parts = vec![self.codec.name().to_string()];
|
||||
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind as a display string
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
match self.kind {
|
||||
StreamKind::Video => "Video",
|
||||
StreamKind::Audio => "Audio",
|
||||
StreamKind::Subtitle => "Subtitle",
|
||||
// ─── AACS state ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// AACS decryption state for a disc.
|
||||
#[derive(Debug)]
|
||||
pub struct AacsState {
|
||||
/// AACS version (1 or 2)
|
||||
pub version: u8,
|
||||
/// Whether bus encryption is enabled (always true for AACS 2.0 / UHD)
|
||||
pub bus_encryption: bool,
|
||||
/// MKB version from disc (e.g. 68, 77)
|
||||
pub mkb_version: Option<u32>,
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf) — hex string with 0x prefix
|
||||
pub disc_hash: String,
|
||||
/// How keys were resolved
|
||||
pub key_source: KeySource,
|
||||
/// Volume Unique Key (16 bytes)
|
||||
pub vuk: [u8; 16],
|
||||
/// Decrypted unit keys (CPS unit number, key)
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Read data key for AACS 2.0 bus decryption — None for AACS 1.0
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// Volume ID (16 bytes) — from SCSI handshake
|
||||
pub volume_id: [u8; 16],
|
||||
}
|
||||
|
||||
/// How AACS keys were resolved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum KeySource {
|
||||
/// VUK found directly in KEYDB by disc hash
|
||||
KeyDb,
|
||||
/// Media key + Volume ID from KEYDB → derived VUK
|
||||
KeyDbDerived,
|
||||
/// MKB + processing keys → media key → VUK
|
||||
ProcessingKey,
|
||||
/// MKB + device keys → subset-difference tree → VUK
|
||||
DeviceKey,
|
||||
}
|
||||
|
||||
impl KeySource {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
KeySource::KeyDb => "KEYDB",
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::ProcessingKey => "MKB + processing key",
|
||||
KeySource::DeviceKey => "MKB + device key",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Disc scanning ──────────────────────────────────────────────────────────
|
||||
|
||||
// Placeholder — the actual implementation will be wired in
|
||||
// when the CLI's disc_info.rs parsing is migrated here.
|
||||
// For now, the CLI does its own parsing.
|
||||
/// Standard KEYDB.cfg search locations (compatible with libaacs).
|
||||
const KEYDB_SEARCH_PATHS: &[&str] = &[
|
||||
".config/aacs/KEYDB.cfg", // relative to $HOME
|
||||
];
|
||||
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
|
||||
|
||||
/// Options for disc scanning.
|
||||
pub struct ScanOptions {
|
||||
/// Path to KEYDB.cfg for AACS key lookup.
|
||||
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
|
||||
pub keydb_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for ScanOptions {
|
||||
fn default() -> Self {
|
||||
ScanOptions { keydb_path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl ScanOptions {
|
||||
/// Create options with a specific KEYDB path.
|
||||
pub fn with_keydb(path: impl Into<std::path::PathBuf>) -> Self {
|
||||
ScanOptions { keydb_path: Some(path.into()) }
|
||||
}
|
||||
|
||||
/// Resolve KEYDB path: explicit path first, then standard locations.
|
||||
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = &self.keydb_path {
|
||||
if p.exists() { return Some(p.clone()); }
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
for relative in KEYDB_SEARCH_PATHS {
|
||||
let p = std::path::PathBuf::from(&home).join(relative);
|
||||
if p.exists() { return Some(p); }
|
||||
}
|
||||
}
|
||||
let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
|
||||
if p.exists() { return Some(p); }
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Disc capacity in GB
|
||||
pub fn capacity_gb(&self) -> f64 {
|
||||
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS decryption.
|
||||
///
|
||||
/// This is the main entry point. After scan(), the Disc is ready:
|
||||
/// - titles are populated with streams
|
||||
/// - AACS keys are derived (if KEYDB available)
|
||||
/// - content can be read and decrypted transparently
|
||||
///
|
||||
/// ```no_run
|
||||
/// use libfreemkv::{DriveSession, Disc};
|
||||
/// use libfreemkv::disc::ScanOptions;
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
/// for title in &disc.titles {
|
||||
/// println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
/// }
|
||||
/// ```
|
||||
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
|
||||
// Step 1: Read capacity
|
||||
let capacity = Self::read_capacity(session)?;
|
||||
|
||||
// Step 2: Parse UDF filesystem
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
|
||||
// Step 3: Find and parse MPLS playlists
|
||||
let mut titles = Vec::new();
|
||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
||||
for entry in &playlist_dir.entries {
|
||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
|
||||
if let Ok(mpls_data) = udf_fs.read_file(session, &path) {
|
||||
if let Some(title) = Self::parse_playlist(session, &udf_fs, &entry.name, &mpls_data) {
|
||||
titles.push(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: longest first
|
||||
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Step 4: Read disc title from META/DL/bdmt_eng.xml
|
||||
let meta_title = Self::read_meta_title(session, &udf_fs);
|
||||
|
||||
// Step 5: Enhance streams with disc config file labels (if available)
|
||||
crate::labels::apply(session, &udf_fs, &mut titles);
|
||||
|
||||
// Step 6: Detect AACS encryption
|
||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
|
||||
// Step 7: If encrypted and KEYDB available, authenticate and derive keys
|
||||
let aacs = if encrypted {
|
||||
if let Some(keydb_path) = opts.resolve_keydb() {
|
||||
match Self::setup_aacs(session, &keydb_path) {
|
||||
Ok(state) => Some(state),
|
||||
Err(_) => None, // keys not found, continue without decryption
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Derive disc format from main title video codec
|
||||
let format = Self::detect_format(&titles);
|
||||
|
||||
// Derive layer count from capacity
|
||||
// BD-25 single layer: up to ~12M sectors (~25GB)
|
||||
// BD-50 dual layer: ~12M-25M sectors (~50GB)
|
||||
// BD-66/100 UHD: 25M+ sectors
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
|
||||
// UHD is always region-free. BD/DVD region parsing TODO.
|
||||
let region = if format == DiscFormat::Uhd {
|
||||
DiscRegion::Free
|
||||
} else {
|
||||
DiscRegion::Free // TODO: parse from index.bdmv
|
||||
};
|
||||
|
||||
Ok(Disc {
|
||||
volume_id: udf_fs.volume_id.clone(),
|
||||
meta_title: meta_title,
|
||||
format,
|
||||
capacity_sectors: capacity,
|
||||
capacity_bytes: capacity as u64 * 2048,
|
||||
layers,
|
||||
titles,
|
||||
region,
|
||||
aacs,
|
||||
encrypted,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set up AACS decryption for this disc.
|
||||
/// Call after scan() to enable transparent content decryption.
|
||||
pub fn setup_aacs(
|
||||
session: &mut DriveSession,
|
||||
keydb_path: &std::path::Path,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
use crate::aacs::handshake;
|
||||
|
||||
// Load KEYDB
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
|
||||
detail: format!("failed to load KEYDB: {}", e),
|
||||
})?;
|
||||
|
||||
// Step 1: Try SCSI handshake for Volume ID + read_data_key
|
||||
// Open a separate transport (AACS auth must happen before raw mode).
|
||||
// If handshake fails (drive doesn't support AACS layer, e.g. raw-mode drives),
|
||||
// fall back to disc-hash-only KEYDB lookup.
|
||||
let device_path = session.device_path().to_string();
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
let mut read_data_key: Option<[u8; 16]> = None;
|
||||
|
||||
if !device_path.is_empty() {
|
||||
if let Ok(mut aacs_session) = DriveSession::open_no_unlock(std::path::Path::new(&device_path)) {
|
||||
if let Ok(hc) = keydb.host_cert.as_ref().ok_or(()) {
|
||||
if let Ok(mut auth) = handshake::aacs_authenticate(
|
||||
&mut aacs_session, &hc.private_key, &hc.certificate,
|
||||
) {
|
||||
vid = handshake::read_volume_id(&mut aacs_session, &mut auth).ok();
|
||||
read_data_key = handshake::read_data_keys(&mut aacs_session, &mut auth)
|
||||
.ok().map(|(rdk, _)| rdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handshake failure is not fatal — we can still resolve via disc hash
|
||||
}
|
||||
|
||||
// Step 2: Read Unit_Key_RO.inf from disc via UDF (uses the unlocked main session)
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsError {
|
||||
detail: "failed to read Unit_Key_RO.inf from disc".into(),
|
||||
})?;
|
||||
|
||||
// Step 3: Read Content Certificate (optional — for AACS version detection)
|
||||
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
|
||||
.ok();
|
||||
|
||||
// Step 4: Resolve keys
|
||||
// If we have VID from handshake, use full 4-path chain.
|
||||
// If no VID (handshake failed), use disc-hash-only KEYDB lookup.
|
||||
let mkb_data = aacs::read_mkb_from_drive(session).ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
// Use a zero VID placeholder if handshake failed — resolve_keys
|
||||
// will still work via disc hash (path 1)
|
||||
let vid_for_resolve = vid.unwrap_or([0u8; 16]);
|
||||
|
||||
let resolved = aacs::resolve_keys(
|
||||
&uk_ro_data,
|
||||
cc_data.as_deref(),
|
||||
&vid_for_resolve,
|
||||
&keydb,
|
||||
mkb_data.as_deref(),
|
||||
).ok_or_else(|| Error::AacsError {
|
||||
detail: "failed to resolve AACS keys — disc not in KEYDB".into(),
|
||||
})?;
|
||||
|
||||
let key_source = match resolved.key_source {
|
||||
1 => KeySource::KeyDb,
|
||||
2 => KeySource::KeyDbDerived,
|
||||
3 => KeySource::ProcessingKey,
|
||||
4 => KeySource::DeviceKey,
|
||||
_ => KeySource::KeyDb,
|
||||
};
|
||||
|
||||
Ok(AacsState {
|
||||
version: if resolved.aacs2 { 2 } else { 1 },
|
||||
bus_encryption: resolved.bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||
key_source,
|
||||
vuk: resolved.vuk,
|
||||
unit_keys: resolved.unit_keys,
|
||||
read_data_key,
|
||||
volume_id: vid.unwrap_or([0u8; 16]),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Detect disc format from the main title's video streams.
|
||||
fn detect_format(titles: &[Title]) -> DiscFormat {
|
||||
for title in titles.iter().take(3) {
|
||||
for stream in &title.streams {
|
||||
if let Stream::Video(v) = stream {
|
||||
if v.resolution.contains("2160") {
|
||||
return DiscFormat::Uhd;
|
||||
}
|
||||
if v.resolution.contains("1080") || v.resolution.contains("720") {
|
||||
return DiscFormat::BluRay;
|
||||
}
|
||||
if v.resolution.contains("480") || v.resolution.contains("576") {
|
||||
return DiscFormat::Dvd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DiscFormat::Unknown
|
||||
}
|
||||
|
||||
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
|
||||
/// Prefers English, falls back to first available language.
|
||||
/// Returns None if META directory is empty or XML has no usable title.
|
||||
fn read_meta_title(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> Option<String> {
|
||||
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
|
||||
for sub in &meta_dir.entries {
|
||||
if !sub.is_dir { continue; }
|
||||
let dl_path = format!("/BDMV/META/{}", sub.name);
|
||||
if let Some(dl_dir) = udf_fs.find_dir(&dl_path) {
|
||||
let xml_files: Vec<_> = dl_dir.entries.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_lowercase().ends_with(".xml"))
|
||||
.collect();
|
||||
|
||||
let eng = xml_files.iter().find(|e| e.name.to_lowercase().contains("eng"));
|
||||
let target = eng.or_else(|| xml_files.first());
|
||||
|
||||
if let Some(entry) = target {
|
||||
let path = format!("{}/{}", dl_path, entry.name);
|
||||
if let Ok(data) = udf_fs.read_file(session, &path) {
|
||||
let xml = String::from_utf8_lossy(&data);
|
||||
if let Some(start) = xml.find("<di:name>") {
|
||||
let s = start + "<di:name>".len();
|
||||
if let Some(end) = xml[s..].find("</di:name>") {
|
||||
let title = xml[s..s + end].trim().to_string();
|
||||
if !title.is_empty() && title != "Blu-ray" {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
||||
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut buf = [0u8; 8];
|
||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
Ok(lba + 1)
|
||||
}
|
||||
|
||||
fn parse_playlist(
|
||||
session: &mut DriveSession,
|
||||
udf_fs: &udf::UdfFs,
|
||||
filename: &str,
|
||||
data: &[u8],
|
||||
) -> Option<Title> {
|
||||
let parsed = mpls::parse(data).ok()?;
|
||||
|
||||
// Calculate duration from play items
|
||||
let duration_ticks: u64 = parsed.play_items.iter()
|
||||
.map(|pi| (pi.out_time.saturating_sub(pi.in_time)) as u64)
|
||||
.sum();
|
||||
let duration_secs = duration_ticks as f64 / 45000.0;
|
||||
|
||||
// Skip very short playlists (< 30 seconds)
|
||||
if duration_secs < 30.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse each clip for size, duration, and sector extents
|
||||
let mut extents = Vec::new();
|
||||
let mut total_size: u64 = 0;
|
||||
let mut clips = Vec::with_capacity(parsed.play_items.len());
|
||||
|
||||
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 mut pkt_count: u32 = 0;
|
||||
|
||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
||||
if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) {
|
||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
||||
pkt_count = clip_info.source_packet_count;
|
||||
total_size += pkt_count as u64 * 192;
|
||||
|
||||
let clip_extents = clip_info.get_extents(play_item.in_time, play_item.out_time);
|
||||
extents.extend(clip_extents);
|
||||
}
|
||||
}
|
||||
|
||||
clips.push(Clip {
|
||||
clip_id: play_item.clip_id.clone(),
|
||||
in_time: play_item.in_time,
|
||||
out_time: play_item.out_time,
|
||||
duration_secs: clip_dur,
|
||||
source_packets: pkt_count,
|
||||
});
|
||||
}
|
||||
|
||||
// Build streams from STN table
|
||||
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
|
||||
let codec = Codec::from_coding_type(s.coding_type);
|
||||
match s.stream_type {
|
||||
1 | 6 | 7 => Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: format_resolution(s.video_format, s.video_rate),
|
||||
frame_rate: format_framerate(s.video_rate),
|
||||
hdr: match s.dynamic_range {
|
||||
1 => HdrFormat::Hdr10,
|
||||
2 => HdrFormat::DolbyVision,
|
||||
_ => HdrFormat::Sdr,
|
||||
},
|
||||
color_space: match s.color_space {
|
||||
1 => ColorSpace::Bt709,
|
||||
2 => ColorSpace::Bt2020,
|
||||
_ => ColorSpace::Unknown,
|
||||
},
|
||||
secondary: s.secondary,
|
||||
label: match s.stream_type {
|
||||
7 => "Dolby Vision EL".to_string(),
|
||||
_ => String::new(),
|
||||
},
|
||||
}),
|
||||
2 | 5 => Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
language: s.language.clone(),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
secondary: s.stream_type == 5,
|
||||
label: String::new(),
|
||||
}),
|
||||
3 => Stream::Subtitle(SubtitleStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
language: s.language.clone(),
|
||||
forced: false, // TODO: parse from MPLS stream attributes
|
||||
}),
|
||||
_ => Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: String::new(),
|
||||
frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Unknown,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
|
||||
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
|
||||
|
||||
Some(Title {
|
||||
playlist: filename.to_string(),
|
||||
playlist_id,
|
||||
duration_secs,
|
||||
size_bytes: total_size,
|
||||
clips,
|
||||
streams,
|
||||
extents,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Decrypted reader ──────────────────────────────────────────────────────
|
||||
|
||||
/// A reader that reads m2ts content, decrypting transparently if needed.
|
||||
pub struct ContentReader<'a> {
|
||||
session: &'a mut DriveSession,
|
||||
aacs: Option<&'a AacsState>,
|
||||
extents: Vec<Extent>,
|
||||
current_extent: usize,
|
||||
current_offset: u32, // sectors into current extent
|
||||
unit_key_idx: usize,
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Open a title for reading. Decryption is automatic — if the disc
|
||||
/// is encrypted and keys were found during scan(), content is decrypted
|
||||
/// on the fly. Unencrypted discs pass through unchanged.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use libfreemkv::{DriveSession, Disc};
|
||||
/// # use libfreemkv::disc::ScanOptions;
|
||||
/// # use std::path::Path;
|
||||
/// # let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
/// let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
/// while let Some(unit) = reader.read_unit().unwrap() {
|
||||
/// // unit is 6144 bytes of decrypted content
|
||||
/// }
|
||||
/// ```
|
||||
pub fn open_title<'a>(&'a self, session: &'a mut DriveSession, title_idx: usize) -> Result<ContentReader<'a>> {
|
||||
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()),
|
||||
})?;
|
||||
|
||||
Ok(ContentReader {
|
||||
session,
|
||||
aacs: self.aacs.as_ref(),
|
||||
extents: title.extents.clone(),
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
unit_key_idx: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ContentReader<'a> {
|
||||
/// Read the next aligned unit (6144 bytes).
|
||||
/// Automatically decrypted if AACS keys are available.
|
||||
/// Returns None when all extents are exhausted.
|
||||
pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let extent = &self.extents[self.current_extent];
|
||||
let lba = extent.start_lba + self.current_offset;
|
||||
|
||||
// Read 3 sectors (one aligned unit)
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
for i in 0..3u32 {
|
||||
let offset = (i as usize) * 2048;
|
||||
let mut sector = [0u8; 2048];
|
||||
session_read_sector(self.session, lba + i, &mut sector)?;
|
||||
unit[offset..offset + 2048].copy_from_slice(§or);
|
||||
}
|
||||
|
||||
// Decrypt if needed
|
||||
if let Some(aacs) = &self.aacs {
|
||||
if crate::aacs::is_unit_encrypted(&unit) {
|
||||
let uk = aacs.unit_keys.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
|
||||
crate::aacs::decrypt_unit_full(
|
||||
&mut unit,
|
||||
&uk,
|
||||
aacs.read_data_key.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance position
|
||||
self.current_offset += 3;
|
||||
if self.current_offset >= extent.sector_count {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
|
||||
Ok(Some(unit))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00, 0x00, 0x01, 0x00,
|
||||
];
|
||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 10_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Format helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn format_resolution(video_format: u8, _video_rate: u8) -> String {
|
||||
match video_format {
|
||||
1 => "480i".into(),
|
||||
2 => "576i".into(),
|
||||
3 => "480p".into(),
|
||||
4 => "1080i".into(),
|
||||
5 => "720p".into(),
|
||||
6 => "1080p".into(),
|
||||
7 => "576p".into(),
|
||||
8 => "2160p".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_framerate(video_rate: u8) -> String {
|
||||
match video_rate {
|
||||
1 => "23.976".into(),
|
||||
2 => "24".into(),
|
||||
3 => "25".into(),
|
||||
4 => "29.97".into(),
|
||||
6 => "50".into(),
|
||||
7 => "59.94".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_channels(audio_format: u8) -> String {
|
||||
match audio_format {
|
||||
1 => "mono".into(),
|
||||
3 => "stereo".into(),
|
||||
6 => "5.1".into(),
|
||||
12 => "7.1".into(),
|
||||
_ if audio_format > 0 => format!("{}ch", audio_format),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_samplerate(audio_rate: u8) -> String {
|
||||
match audio_rate {
|
||||
1 => "48kHz".into(),
|
||||
4 => "96kHz".into(),
|
||||
5 => "192kHz".into(),
|
||||
12 => "48/192kHz".into(),
|
||||
14 => "48/96kHz".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
+101
-48
@@ -1,8 +1,13 @@
|
||||
//! High-level drive session — the main API for consumers.
|
||||
//! Drive session — open, identify, unlock, and read from optical drives.
|
||||
//!
|
||||
//! Opens a drive, identifies it via standard SCSI commands,
|
||||
//! matches it against the profile database, and provides
|
||||
//! raw disc access methods.
|
||||
//! `DriveSession` is the entry point for all drive interaction. It handles
|
||||
//! device identification, profile matching, platform-specific unlock, and
|
||||
//! provides both raw sector reads and standard SCSI command execution.
|
||||
//!
|
||||
//! Two open modes:
|
||||
//! - `open()` — identify + unlock. Ready for reading immediately.
|
||||
//! - `open_no_unlock()` — identify only. Used for AACS authentication
|
||||
//! which must happen before the drive enters raw mode.
|
||||
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -12,28 +17,41 @@ use crate::profile::{self, DriveProfile, Chipset};
|
||||
use crate::platform::{Platform, DriveStatus};
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
|
||||
/// A complete drive session.
|
||||
/// A drive session with identification, platform, and SCSI transport.
|
||||
///
|
||||
/// Handles: identify → match profile → create platform → execute commands.
|
||||
/// Created via `DriveSession::open()` or `DriveSession::open_no_unlock()`.
|
||||
/// All disc reading goes through this struct.
|
||||
pub struct DriveSession {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
platform: Box<dyn Platform>,
|
||||
pub profile: DriveProfile,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
/// Open a drive, identify it, and find the matching profile.
|
||||
/// Uses the bundled profile database — no external files needed.
|
||||
/// Open a drive, identify it, match a profile, and unlock for raw reads.
|
||||
///
|
||||
/// This is the standard entry point. After `open()`, the drive is ready
|
||||
/// for sector reads, disc scanning, and content extraction.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let mut session = Self::open_no_unlock(device)?;
|
||||
session.wait_ready()?;
|
||||
let _ = session.unlock(); // silently ignore — unencrypted discs don't need it
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Open a drive WITHOUT unlocking.
|
||||
///
|
||||
/// Used when AACS authentication must happen before raw mode.
|
||||
/// The AACS SCSI handshake requires the drive's standard firmware
|
||||
/// state — unlocking puts the drive in vendor-specific raw mode
|
||||
/// which disables the AACS layer.
|
||||
pub fn open_no_unlock(device: &Path) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let profiles = profile::load_bundled()?;
|
||||
|
||||
// Identify drive via standard SCSI commands
|
||||
// SPC-4 §6.4 (INQUIRY) + MMC-6 §5.3.10 (Feature 010Ch)
|
||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||||
|
||||
// Match drive to a profile by INQUIRY fields
|
||||
let profile = profile::find_by_drive_id(&profiles, &drive_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::UnsupportedDrive {
|
||||
@@ -42,59 +60,61 @@ impl DriveSession {
|
||||
product_revision: drive_id.product_revision.trim().to_string(),
|
||||
})?;
|
||||
|
||||
let platform: Box<dyn Platform> = match profile.chipset {
|
||||
Chipset::MediaTek => {
|
||||
Box::new(Mt1959::new(profile.clone()))
|
||||
}
|
||||
Chipset::Renesas => {
|
||||
return Err(Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let platform = create_platform(&profile, &drive_id)?;
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open with an explicit profile (skip auto-detection).
|
||||
/// Open with an explicit profile, skipping auto-detection.
|
||||
pub fn open_with_profile(device: &Path, profile: DriveProfile) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||||
|
||||
let platform: Box<dyn Platform> = match profile.chipset {
|
||||
Chipset::MediaTek => {
|
||||
Box::new(Mt1959::new(profile.clone()))
|
||||
}
|
||||
Chipset::Renesas => {
|
||||
return Err(Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let platform = create_platform(&profile, &drive_id)?;
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Activate raw disc access mode.
|
||||
/// Wait for the drive to become ready (disc spun up).
|
||||
/// Polls TEST UNIT READY up to 30 seconds.
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // TEST UNIT READY
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
if self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5000
|
||||
).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
Err(Error::DeviceNotFound {
|
||||
path: format!("{}: drive not ready after 30s", self.device_path),
|
||||
})
|
||||
}
|
||||
|
||||
/// Device path this session was opened on.
|
||||
pub fn device_path(&self) -> &str {
|
||||
&self.device_path
|
||||
}
|
||||
|
||||
/// Activate raw disc access mode (vendor-specific unlock).
|
||||
pub fn unlock(&mut self) -> Result<()> {
|
||||
self.platform.unlock(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Check if raw disc access mode is enabled.
|
||||
/// Check if raw disc access mode is active.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.platform.is_unlocked()
|
||||
}
|
||||
@@ -119,33 +139,66 @@ impl DriveSession {
|
||||
self.platform.calibrate(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Read raw disc sectors.
|
||||
/// Read raw disc sectors via platform-specific command.
|
||||
pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf)
|
||||
}
|
||||
|
||||
/// Generic probe command.
|
||||
/// Platform-specific probe command.
|
||||
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
|
||||
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length)
|
||||
}
|
||||
|
||||
/// Standard READ(10) — reads unencrypted sectors (UDF filesystem, etc).
|
||||
/// Does not require unlock. Use read_sectors() for raw/encrypted content.
|
||||
/// Standard SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
|
||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
0x28, 0x00, // READ(10), no flags
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)?;
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Send a raw SCSI CDB. Used by UDF reader and disc structure parsers.
|
||||
pub fn scsi_execute(&mut self, cdb: &[u8], direction: crate::scsi::DataDirection, buf: &mut [u8], timeout_ms: u32) -> Result<crate::scsi::ScsiResult> {
|
||||
/// Eject the disc tray.
|
||||
///
|
||||
/// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any
|
||||
/// locks, then START STOP UNIT with LoEj=1 to open the tray.
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
// PREVENT ALLOW MEDIUM REMOVAL: allow removal
|
||||
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
|
||||
// START STOP UNIT: LoEj=1, Start=0
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
|
||||
pub fn scsi_execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: crate::scsi::DataDirection,
|
||||
buf: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<crate::scsi::ScsiResult> {
|
||||
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the platform-specific driver for a given chipset.
|
||||
fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result<Box<dyn Platform>> {
|
||||
match profile.chipset {
|
||||
Chipset::MediaTek => Ok(Box::new(Mt1959::new(profile.clone()))),
|
||||
Chipset::Renesas => Err(Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
+83
-60
@@ -1,97 +1,120 @@
|
||||
/// libfreemkv error codes.
|
||||
///
|
||||
/// The library returns structured error codes with context data.
|
||||
/// Applications are responsible for formatting user-facing messages.
|
||||
/// This keeps the library locale-independent and testable.
|
||||
//! Error types for libfreemkv.
|
||||
//!
|
||||
//! Every error carries a numeric code for programmatic handling.
|
||||
//! No user-facing English text — applications format their own messages.
|
||||
//! This keeps the library locale-independent and testable.
|
||||
//!
|
||||
//! # Error Code Ranges
|
||||
//!
|
||||
//! | Range | Category |
|
||||
//! |-------|----------|
|
||||
//! | E1xxx | Device errors |
|
||||
//! | E2xxx | Profile errors |
|
||||
//! | E3xxx | Unlock errors |
|
||||
//! | E4xxx | SCSI errors |
|
||||
//! | E5xxx | I/O errors |
|
||||
//! | E6xxx | Disc format errors |
|
||||
//! | E7xxx | AACS errors |
|
||||
|
||||
/// Error code table.
|
||||
///
|
||||
/// | Code | Name | Meaning |
|
||||
/// |------|------|---------|
|
||||
/// | 1000 | DeviceNotFound | Device path doesn't exist or can't be opened |
|
||||
/// | 1001 | DevicePermission | Device exists but permission denied |
|
||||
/// | 2000 | UnsupportedDrive | Drive not in profile database |
|
||||
/// | 2001 | ProfileNotFound | Specific firmware version not in database |
|
||||
/// | 2002 | ProfileParse | Profile database is malformed |
|
||||
/// | 3000 | UnlockFailed | Drive rejected unlock command |
|
||||
/// | 3001 | SignatureMismatch | Wrong signature returned by drive |
|
||||
/// | 3002 | NotUnlocked | Raw read attempted before unlock |
|
||||
/// | 3003 | NotCalibrated | Raw read attempted before calibrate |
|
||||
/// | 4000 | ScsiError | SCSI command failed |
|
||||
/// | 4001 | ScsiTimeout | SCSI command timed out |
|
||||
/// | 5000 | IoError | OS-level I/O error |
|
||||
// ── Error codes (single source of truth) ────────────────────────────────────
|
||||
|
||||
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
||||
pub const E_DEVICE_PERMISSION: u16 = 1001;
|
||||
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
|
||||
pub const E_PROFILE_NOT_FOUND: u16 = 2001;
|
||||
pub const E_PROFILE_PARSE: u16 = 2002;
|
||||
pub const E_UNLOCK_FAILED: u16 = 3000;
|
||||
pub const E_SIGNATURE_MISMATCH: u16 = 3001;
|
||||
pub const E_NOT_UNLOCKED: u16 = 3002;
|
||||
pub const E_NOT_CALIBRATED: u16 = 3003;
|
||||
pub const E_SCSI_ERROR: u16 = 4000;
|
||||
pub const E_SCSI_TIMEOUT: u16 = 4001;
|
||||
pub const E_IO_ERROR: u16 = 5000;
|
||||
pub const E_DISC_ERROR: u16 = 6000;
|
||||
pub const E_AACS_ERROR: u16 = 7000;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Structured error with numeric code and context data.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
// 1xxx — Device errors
|
||||
DeviceNotFound { path: String },
|
||||
DevicePermission { path: String },
|
||||
|
||||
// 2xxx — Profile errors
|
||||
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
|
||||
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
|
||||
ProfileParse { detail: String },
|
||||
|
||||
// 3xxx — Unlock errors
|
||||
UnlockFailed { detail: String },
|
||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||
NotUnlocked,
|
||||
NotCalibrated,
|
||||
|
||||
// 4xxx — SCSI errors
|
||||
ScsiError { opcode: u8, status: u8, sense_key: u8 },
|
||||
ScsiTimeout { opcode: u8 },
|
||||
|
||||
// 5xxx — I/O errors
|
||||
IoError { source: std::io::Error },
|
||||
|
||||
// 6xxx — Disc format errors
|
||||
DiscError { detail: String },
|
||||
AacsError { detail: String },
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Numeric error code for programmatic handling.
|
||||
/// Numeric error code.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::DeviceNotFound { .. } => 1000,
|
||||
Error::DevicePermission { .. } => 1001,
|
||||
Error::UnsupportedDrive { .. } => 2000,
|
||||
Error::ProfileNotFound { .. } => 2001,
|
||||
Error::ProfileParse { .. } => 2002,
|
||||
Error::UnlockFailed { .. } => 3000,
|
||||
Error::SignatureMismatch { .. } => 3001,
|
||||
Error::NotUnlocked => 3002,
|
||||
Error::NotCalibrated => 3003,
|
||||
Error::ScsiError { .. } => 4000,
|
||||
Error::ScsiTimeout { .. } => 4001,
|
||||
Error::IoError { .. } => 5000,
|
||||
Error::DiscError { .. } => 6000,
|
||||
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
|
||||
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
||||
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
||||
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
|
||||
Error::ProfileParse { .. } => E_PROFILE_PARSE,
|
||||
Error::UnlockFailed { .. } => E_UNLOCK_FAILED,
|
||||
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||
Error::NotUnlocked => E_NOT_UNLOCKED,
|
||||
Error::NotCalibrated => E_NOT_CALIBRATED,
|
||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
|
||||
Error::IoError { .. } => E_IO_ERROR,
|
||||
Error::DiscError { .. } => E_DISC_ERROR,
|
||||
Error::AacsError { .. } => E_AACS_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Display — terse, for logs. Applications should format their own messages.
|
||||
/// Display format: "E{code}: {context}" — terse, for logs.
|
||||
/// Applications should format their own user-facing messages using code() and fields.
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::DeviceNotFound { path } => write!(f, "E1000: device not found: {path}"),
|
||||
Error::DevicePermission { path } => write!(f, "E1001: permission denied: {path}"),
|
||||
Error::DeviceNotFound { path } =>
|
||||
write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path),
|
||||
Error::DevicePermission { path } =>
|
||||
write!(f, "E{}: {}", E_DEVICE_PERMISSION, path),
|
||||
Error::UnsupportedDrive { vendor_id, product_id, product_revision } =>
|
||||
write!(f, "E2000: unsupported drive: {} {} {}", vendor_id.trim(), product_id.trim(), product_revision.trim()),
|
||||
write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE,
|
||||
vendor_id.trim(), product_id.trim(), product_revision.trim()),
|
||||
Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } =>
|
||||
write!(f, "E2001: no profile: {} {} {}", vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
|
||||
Error::ProfileParse { detail } => write!(f, "E2002: profile parse: {detail}"),
|
||||
Error::UnlockFailed { detail } => write!(f, "E3000: unlock failed: {detail}"),
|
||||
write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND,
|
||||
vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
|
||||
Error::ProfileParse { detail } =>
|
||||
write!(f, "E{}: {}", E_PROFILE_PARSE, detail),
|
||||
Error::UnlockFailed { detail } =>
|
||||
write!(f, "E{}: {}", E_UNLOCK_FAILED, detail),
|
||||
Error::SignatureMismatch { expected, got } =>
|
||||
write!(f, "E3001: signature mismatch: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
|
||||
write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
|
||||
E_SIGNATURE_MISMATCH,
|
||||
expected[0], expected[1], expected[2], expected[3],
|
||||
got[0], got[1], got[2], got[3]),
|
||||
Error::NotUnlocked => write!(f, "E3002: not unlocked"),
|
||||
Error::NotCalibrated => write!(f, "E3003: not calibrated"),
|
||||
Error::NotUnlocked =>
|
||||
write!(f, "E{}", E_NOT_UNLOCKED),
|
||||
Error::NotCalibrated =>
|
||||
write!(f, "E{}", E_NOT_CALIBRATED),
|
||||
Error::ScsiError { opcode, status, sense_key } =>
|
||||
write!(f, "E4000: SCSI 0x{opcode:02x} failed: status=0x{status:02x} sense=0x{sense_key:02x}"),
|
||||
Error::ScsiTimeout { opcode } => write!(f, "E4001: SCSI 0x{opcode:02x} timeout"),
|
||||
Error::IoError { source } => write!(f, "E5000: {source}"),
|
||||
Error::DiscError { detail } => write!(f, "E6000: disc: {detail}"),
|
||||
write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}",
|
||||
E_SCSI_ERROR, opcode, status, sense_key),
|
||||
Error::ScsiTimeout { opcode } =>
|
||||
write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, opcode),
|
||||
Error::IoError { source } =>
|
||||
write!(f, "E{}: {}", E_IO_ERROR, source),
|
||||
Error::DiscError { detail } =>
|
||||
write!(f, "E{}: {}", E_DISC_ERROR, detail),
|
||||
Error::AacsError { detail } =>
|
||||
write!(f, "E{}: {}", E_AACS_ERROR, detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -39,6 +39,9 @@ pub struct DriveId {
|
||||
/// Format: CCYYMMDDHHMI (12 ASCII characters)
|
||||
pub firmware_date: String,
|
||||
|
||||
/// Drive serial number — GET CONFIGURATION Feature 0108h
|
||||
pub serial_number: String,
|
||||
|
||||
/// Raw 96-byte INQUIRY response for additional parsing if needed.
|
||||
pub raw_inquiry: Vec<u8>,
|
||||
}
|
||||
@@ -63,10 +66,29 @@ impl DriveId {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(Self::from_inquiry(&inquiry, &firmware_date))
|
||||
// GET CONFIGURATION Feature 0108h — Serial Number
|
||||
let mut gc_serial = vec![0u8; 256];
|
||||
let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let serial_number = if let Ok(r) = transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) {
|
||||
if r.bytes_transferred > 12 {
|
||||
String::from_utf8_lossy(&gc_serial[12..r.bytes_transferred])
|
||||
.trim().to_string()
|
||||
} else { String::new() }
|
||||
} else { String::new() };
|
||||
|
||||
Ok(DriveId {
|
||||
vendor_id: ascii_field(&inquiry, 8, 16),
|
||||
product_id: ascii_field(&inquiry, 16, 32),
|
||||
product_revision: ascii_field(&inquiry, 32, 36),
|
||||
vendor_specific: ascii_field(&inquiry, 36, 43),
|
||||
firmware_date,
|
||||
serial_number,
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build identity from raw INQUIRY bytes and firmware date string.
|
||||
/// Used by tests and when serial isn't available.
|
||||
pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self {
|
||||
DriveId {
|
||||
vendor_id: ascii_field(inquiry, 8, 16),
|
||||
@@ -74,6 +96,7 @@ impl DriveId {
|
||||
product_revision: ascii_field(inquiry, 32, 36),
|
||||
vendor_specific: ascii_field(inquiry, 36, 43),
|
||||
firmware_date: firmware_date.to_string(),
|
||||
serial_number: String::new(),
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
//! BD-J JAR parser — extract audio/subtitle track labels from disc menus.
|
||||
//!
|
||||
//! Blu-ray discs with BD-J menus store the menu application as Java JAR files
|
||||
//! in BDMV/JAR/. These contain .class files with string constants that label
|
||||
//! audio and subtitle tracks.
|
||||
//!
|
||||
//! Multiple label formats exist across studios:
|
||||
//! - Label format: "eng_MLP_", "fra_AudioStream3" (Warner UHD, e.g. Barbie, Dune)
|
||||
//! - TextField format: "TextField,Audio1,English Dolby Atmos,..." (A24/Lionsgate, e.g. Civil War)
|
||||
//!
|
||||
//! Labels match to streams by STN index — the Nth audio label in the JAR
|
||||
//! corresponds to the Nth audio stream in the MPLS STN table. This is defined
|
||||
//! by the BD-J API where stream numbers = STN indices (1-based).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// Labels extracted from a BD-J JAR file.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct JarLabels {
|
||||
/// Audio track labels in STN order
|
||||
pub audio: Vec<TrackLabel>,
|
||||
/// Subtitle track labels in STN order
|
||||
pub subtitle: Vec<TrackLabel>,
|
||||
/// Playlist purpose labels (MAIN_FEATURE, FORCED_TRAILER, etc.)
|
||||
pub playlists: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parsed track label from the JAR.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackLabel {
|
||||
/// Human-readable description (e.g. "English Dolby Atmos", "TrueHD", "Descriptive Audio (US)")
|
||||
pub description: String,
|
||||
/// ISO 639-2 language code if available (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Codec hint if available (e.g. "MLP"=TrueHD, "AC3"=DD, "DTS")
|
||||
pub codec_hint: String,
|
||||
/// The raw string from the class file
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
/// Extract track labels from a JAR file (raw ZIP bytes).
|
||||
///
|
||||
/// Tries multiple format parsers. Returns None if no labels found.
|
||||
pub fn extract_labels(jar_data: &[u8]) -> Option<JarLabels> {
|
||||
let strings = extract_all_jar_strings(jar_data)?;
|
||||
|
||||
// Try each format parser in order
|
||||
try_textfield_format(&strings)
|
||||
.or_else(|| try_label_format(&strings))
|
||||
.or_else(|| try_playlist_only(&strings))
|
||||
}
|
||||
|
||||
// ── Format 1: TextField (A24/Lionsgate) ─────────────────────────────────────
|
||||
//
|
||||
// Pattern: "TextField,Audio{N},{description},..."
|
||||
// "TextField,Subtitle{N},{description},..."
|
||||
// Found in: Civil War, and similar A24/Lionsgate discs
|
||||
|
||||
fn try_textfield_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio: Vec<(u32, String, String)> = Vec::new(); // (index, description, raw)
|
||||
let mut subtitle: Vec<(u32, String, String)> = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if s.starts_with("TextField,Audio") {
|
||||
// TextField,Audio{N},{description},...
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1]; // "Audio1", "Audio2", etc.
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Audio") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.is_empty() {
|
||||
audio.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if s.starts_with("TextField,Subtitle") {
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1];
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Subtitle") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.eq_ignore_ascii_case("None") && !desc.is_empty() {
|
||||
subtitle.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sort by index to ensure STN order
|
||||
audio.sort_by_key(|a| a.0);
|
||||
subtitle.sort_by_key(|s| s.0);
|
||||
|
||||
Some(JarLabels {
|
||||
audio: audio.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
subtitle: subtitle.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
playlists,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 2: Label strings (Warner UHD) ────────────────────────────────────
|
||||
//
|
||||
// Pattern: "eng_MLP_", "fra_AudioStream3", "dan_PGStream4"
|
||||
// Found in: Barbie, Dune Part Two, and similar Warner UHD discs
|
||||
|
||||
fn try_label_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio = Vec::new();
|
||||
let mut subtitle = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if let Some(label) = parse_label_string(s) {
|
||||
if label.is_audio && !audio.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
audio.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
} else if label.is_subtitle && !subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
subtitle.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(JarLabels { audio, subtitle, playlists })
|
||||
}
|
||||
|
||||
struct ParsedLabel {
|
||||
description: String,
|
||||
language: String,
|
||||
codec_hint: String,
|
||||
raw: String,
|
||||
is_audio: bool,
|
||||
is_subtitle: bool,
|
||||
}
|
||||
|
||||
fn parse_label_string(s: &str) -> Option<ParsedLabel> {
|
||||
let clean = s.trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.splitn(3, '_').collect();
|
||||
if parts.len() < 2 { return None; }
|
||||
|
||||
let language = parts[0];
|
||||
if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hint = parts[1];
|
||||
let variant = if parts.len() > 2 { parts[2] } else { "" };
|
||||
|
||||
// Only set description when the JAR adds info beyond what MPLS provides.
|
||||
// Codec (MLP, AC3, DTS) is already known from the stream. Don't make up labels.
|
||||
let (description, codec_hint, is_audio, is_subtitle) = match hint {
|
||||
"MLP" => (String::new(), "MLP".to_string(), true, false),
|
||||
"AC3" => (String::new(), "AC3".to_string(), true, false),
|
||||
"DTS" => (String::new(), "DTS".to_string(), true, false),
|
||||
"LPCM" => (String::new(), "LPCM".to_string(), true, false),
|
||||
"ADES" => {
|
||||
// Descriptive Audio — real value-add, can't be derived from MPLS
|
||||
let d = if variant.is_empty() { "Descriptive Audio".to_string() }
|
||||
else { format!("Descriptive Audio ({})", variant) };
|
||||
(d, "ADES".to_string(), true, false)
|
||||
}
|
||||
h if h.starts_with("AudioStream") => (String::new(), String::new(), true, false),
|
||||
h if h.starts_with("PGStream") => (String::new(), String::new(), false, true),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ParsedLabel {
|
||||
description, language: language.to_string(), codec_hint,
|
||||
raw: s.to_string(), is_audio, is_subtitle,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 3: Playlist-only ─────────────────────────────────────────────────
|
||||
//
|
||||
// Some JARs have no track labels but do have playlist purpose markers.
|
||||
|
||||
fn try_playlist_only(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut playlists = Vec::new();
|
||||
for s in strings {
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
if playlists.is_empty() { return None; }
|
||||
Some(JarLabels { audio: Vec::new(), subtitle: Vec::new(), playlists })
|
||||
}
|
||||
|
||||
// ── Common helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_playlist_label(s: &str, playlists: &mut Vec<String>) {
|
||||
if matches!(s.as_ref(),
|
||||
"MAIN_FEATURE" | "MAIN_FEATURE_INTRO" |
|
||||
"FORCED_TRAILER" | "INTL_FORCED_TRAILER" |
|
||||
"commentary_extras" | "extras"
|
||||
) {
|
||||
if !playlists.iter().any(|p| p == s) {
|
||||
playlists.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all UTF-8 string constants from all .class files in a JAR.
|
||||
fn extract_all_jar_strings(jar_data: &[u8]) -> Option<Vec<String>> {
|
||||
let cursor = std::io::Cursor::new(jar_data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut all_strings = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).ok()?;
|
||||
if !file.name().ends_with(".class") { continue; }
|
||||
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).ok()?;
|
||||
|
||||
extract_class_strings(&data, &mut all_strings);
|
||||
}
|
||||
|
||||
if all_strings.is_empty() { return None; }
|
||||
Some(all_strings)
|
||||
}
|
||||
|
||||
/// Extract UTF-8 string constants from a Java .class file's constant pool.
|
||||
fn extract_class_strings(data: &[u8], out: &mut Vec<String>) {
|
||||
if data.len() < 10 || &data[0..4] != &[0xCA, 0xFE, 0xBA, 0xBE] {
|
||||
return;
|
||||
}
|
||||
|
||||
let cp_count = ((data[8] as u16) << 8 | data[9] as u16) as usize;
|
||||
let mut pos = 10;
|
||||
let mut entry = 1;
|
||||
|
||||
while entry < cp_count && pos < data.len() {
|
||||
let tag = data[pos];
|
||||
pos += 1;
|
||||
|
||||
match tag {
|
||||
// CONSTANT_Utf8
|
||||
1 => {
|
||||
if pos + 2 > data.len() { break; }
|
||||
let len = ((data[pos] as usize) << 8) | data[pos + 1] as usize;
|
||||
pos += 2;
|
||||
if pos + len > data.len() { break; }
|
||||
|
||||
if let Ok(s) = std::str::from_utf8(&data[pos..pos + len]) {
|
||||
if s.len() >= 3 && s.len() <= 500 {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
3 | 4 => { pos += 4; }
|
||||
5 | 6 => { pos += 8; entry += 1; }
|
||||
7 | 8 | 16 => { pos += 2; }
|
||||
9 | 10 | 11 | 12 | 18 => { pos += 4; }
|
||||
15 => { pos += 3; }
|
||||
_ => { break; }
|
||||
}
|
||||
entry += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_textfield_audio() {
|
||||
let strings = vec![
|
||||
"TextField,Audio1,English Dolby Atmos,Font,296,763,275,25,left".to_string(),
|
||||
"TextField,Audio2,English Descriptive Audio,Font,296,803,275,25,left".to_string(),
|
||||
"TextField,Audio3,Spanish 5.1 Dolby Digital,Font,296,843,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, "English Dolby Atmos");
|
||||
assert_eq!(labels.audio[1].description, "English Descriptive Audio");
|
||||
assert_eq!(labels.audio[2].description, "Spanish 5.1 Dolby Digital");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_textfield_subtitle_skips_none() {
|
||||
let strings = vec![
|
||||
"TextField,Subtitle0,None,Font,1312,843,275,25,left".to_string(),
|
||||
"TextField,Subtitle1,English SDH,Font,1312,763,275,25,left".to_string(),
|
||||
"TextField,Subtitle2,Spanish,Font,1312,803,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 2);
|
||||
assert_eq!(labels.subtitle[0].description, "English SDH");
|
||||
assert_eq!(labels.subtitle[1].description, "Spanish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_format_audio() {
|
||||
let strings = vec![
|
||||
"eng_MLP_".to_string(),
|
||||
"eng_ADES_US_".to_string(),
|
||||
"fra_AudioStream3".to_string(),
|
||||
];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, ""); // MLP — codec already known from stream
|
||||
assert_eq!(labels.audio[1].description, "Descriptive Audio (US)"); // ADES — real value-add
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_format_subtitle() {
|
||||
let strings = vec!["dan_PGStream4".to_string()];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_labels() {
|
||||
let strings = vec!["java/lang/Object".to_string(), "toString".to_string()];
|
||||
assert!(try_textfield_format(&strings).is_none());
|
||||
assert!(try_label_format(&strings).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml`
|
||||
//!
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
|
||||
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
||||
|
||||
let stream_infos = parse_stream_infos(sp_text);
|
||||
if stream_infos.is_empty() { return None; }
|
||||
|
||||
// Stream number mapping from playbackconfig.xml
|
||||
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
||||
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
|
||||
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
|
||||
parse_playback_config(pc_text, &mut stream_map);
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut audio_idx: u16 = 1;
|
||||
let mut sub_idx: u16 = 1;
|
||||
|
||||
for info in &stream_infos {
|
||||
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
|
||||
match info.stream_type {
|
||||
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
|
||||
StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n }
|
||||
}
|
||||
});
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type: info.stream_type,
|
||||
language: info.language.clone(),
|
||||
name: String::new(),
|
||||
purpose: info.purpose,
|
||||
qualifier: info.qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: info.variant.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
struct StreamInfo {
|
||||
id: String,
|
||||
stream_type: StreamLabelType,
|
||||
language: String,
|
||||
variant: String,
|
||||
purpose: LabelPurpose,
|
||||
qualifier: LabelQualifier,
|
||||
}
|
||||
|
||||
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
|
||||
let mut infos = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < xml.len() {
|
||||
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Audio)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Subtitle)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
let end_tag = match stream_type {
|
||||
StreamLabelType::Audio => "</AudioStreamInfos>",
|
||||
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
|
||||
};
|
||||
|
||||
let block_end = match xml[tag..].find(end_tag) {
|
||||
Some(p) => tag + p + end_tag.len(),
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block = &xml[tag..block_end];
|
||||
let id = extract_tag(block, "ID").unwrap_or_default();
|
||||
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
|
||||
let content = extract_tag(block, "Content").unwrap_or_default();
|
||||
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
|
||||
|
||||
let (language, variant) = if lang_id.contains('_') {
|
||||
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
|
||||
(parts[0].to_lowercase(), parts[1].to_string())
|
||||
} else {
|
||||
(lang_id.to_lowercase(), String::new())
|
||||
};
|
||||
|
||||
let purpose = match content.as_str() {
|
||||
"COMMENTARY" => LabelPurpose::Commentary,
|
||||
_ => LabelPurpose::Normal,
|
||||
};
|
||||
|
||||
let qualifier = match qualifier_str.as_str() {
|
||||
"SDH" => LabelQualifier::Sdh,
|
||||
"DS" => LabelQualifier::DescriptiveService,
|
||||
_ => LabelQualifier::None,
|
||||
};
|
||||
|
||||
infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier });
|
||||
pos = block_end;
|
||||
}
|
||||
infos
|
||||
}
|
||||
|
||||
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
|
||||
let mut pos = 0;
|
||||
while pos < xml.len() {
|
||||
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
|
||||
Some(p + pos)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitlesStreams>") {
|
||||
Some(p + pos)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tag_start = match tag_start {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block_end = xml[tag_start..].find("</AudioStreams>")
|
||||
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
|
||||
.map(|p| tag_start + p + 20)
|
||||
.unwrap_or(xml.len());
|
||||
|
||||
let block = &xml[tag_start..block_end];
|
||||
|
||||
if let (Some(stream_id_str), Some(info_id)) = (extract_tag(block, "StreamID"), extract_tag(block, "StreamInfo_ID")) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
}
|
||||
|
||||
pos = block_end;
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{}>", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)? + start;
|
||||
Some(xml[start..end].trim().to_string())
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Warner CTRM — `menu_base.prop` and/or `language_streams.txt`
|
||||
//!
|
||||
//! Two sub-formats from the same framework. A disc may have one or both.
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "menu_base.prop")
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
// Try language_streams.txt first (richer structured data)
|
||||
let ls_labels = parse_language_streams(session, udf);
|
||||
|
||||
// Try menu_base.prop (stream numbers + key names)
|
||||
let mb_labels = parse_menu_base(session, udf);
|
||||
|
||||
// If we have both, merge: language_streams for structure, menu_base for names
|
||||
match (ls_labels, mb_labels) {
|
||||
(Some(ls), Some(mb)) => Some(merge(ls, mb)),
|
||||
(Some(ls), None) => Some(ls),
|
||||
(None, Some(mb)) => Some(mb),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
// language_streams has better type/purpose data, menu_base has button names
|
||||
// Match by stream number + type, take name from menu_base
|
||||
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 label.name.is_empty() && !mb_match.name.is_empty() {
|
||||
label.name = mb_match.name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() < 4 { continue; }
|
||||
|
||||
let type_str = parts[1];
|
||||
let stream_num: u16 = match parts[2].parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let language = parts[3].to_string();
|
||||
let variant = if parts.len() > 4 { parts[4].to_string() } else { String::new() };
|
||||
|
||||
let (stream_type, purpose, qualifier) = match type_str {
|
||||
"audio_production" => (StreamLabelType::Audio, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"audio_commentary" => (StreamLabelType::Audio, LabelPurpose::Commentary, LabelQualifier::None),
|
||||
"audio_ime" => (StreamLabelType::Audio, LabelPurpose::Ime, LabelQualifier::None),
|
||||
"subtitle_production" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_commentary" => (StreamLabelType::Subtitle, LabelPurpose::Commentary, LabelQualifier::None),
|
||||
"subtitle_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::Forced),
|
||||
"subtitle_dual" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_bonus" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_ime" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::None),
|
||||
"subtitle_ime_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::Forced),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Classify variant code
|
||||
let mut codec_hint = String::new();
|
||||
let mut variant_code = String::new();
|
||||
let mut final_purpose = purpose;
|
||||
|
||||
if !variant.is_empty() {
|
||||
match variant.as_str() {
|
||||
// Codec variants — use shared label vocab
|
||||
"atmos" | "MLP" | "AC3" | "DTS" | "DDL" => {
|
||||
codec_hint = vocab::codec(&variant).to_string();
|
||||
}
|
||||
// Purpose variants
|
||||
"eda" => final_purpose = LabelPurpose::Descriptive,
|
||||
// Dialect variants — pass through raw code from disc
|
||||
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
|
||||
variant_code = variant.clone();
|
||||
}
|
||||
// Unknown — store as-is in codec_hint
|
||||
_ => codec_hint = variant.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name: String::new(),
|
||||
purpose: final_purpose,
|
||||
qualifier,
|
||||
codec_hint,
|
||||
variant: variant_code,
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
|
||||
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Parse key=value, group by prefix
|
||||
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
let eq_pos = match line.find('=') {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let full_key = &line[..eq_pos];
|
||||
let value = &line[eq_pos + 1..];
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
for (prefix, props) in &entries {
|
||||
// Audio: has "streamNumber" or "audioStream" and audio-related class
|
||||
let is_audio = props.get("class").map_or(false, |c| c.contains("AudioButton"))
|
||||
|| prefix.starts_with("audio_");
|
||||
let is_subtitle = props.get("class").map_or(false, |c| c.contains("SubtitleButton"))
|
||||
|| prefix.starts_with("subtitle_");
|
||||
|
||||
let stream_num_str = props.get("streamNumber")
|
||||
.or_else(|| props.get("audioStream"))
|
||||
.or_else(|| props.get("subtitleStream"));
|
||||
|
||||
let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if !is_audio && !is_subtitle { continue; }
|
||||
|
||||
let name = props.get("name").cloned().unwrap_or_default();
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
let purpose = if name_lower.contains("comment") || prefix.contains("comm") {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
let qualifier = if is_subtitle && name_lower.contains("sdh") {
|
||||
LabelQualifier::Sdh
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
let stream_type = if is_audio { StreamLabelType::Audio } else { StreamLabelType::Subtitle };
|
||||
|
||||
// Try to extract language from audioLanguage/subtitleLanguage prop
|
||||
let language = props.get("audioLanguage")
|
||||
.or_else(|| props.get("subtitleLanguage"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name,
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
|
||||
Some(labels)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Stream label extraction from BD-J disc files.
|
||||
//!
|
||||
//! Each parser module represents one BD-J authoring framework.
|
||||
//! To add a new format:
|
||||
//! 1. Create `src/labels/myformat.rs`
|
||||
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
|
||||
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
|
||||
|
||||
mod paramount;
|
||||
mod criterion;
|
||||
mod pixelogic;
|
||||
mod ctrm;
|
||||
pub mod vocab;
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use crate::disc::{Title, Stream};
|
||||
|
||||
/// A stream label extracted from disc config files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamLabel {
|
||||
/// STN index (1-based)
|
||||
pub stream_number: u16,
|
||||
/// Audio or Subtitle
|
||||
pub stream_type: StreamLabelType,
|
||||
/// ISO 639-2 language code
|
||||
pub language: String,
|
||||
/// Display name (e.g. "Commentary", "Descriptive Audio")
|
||||
pub name: String,
|
||||
/// Stream purpose
|
||||
pub purpose: LabelPurpose,
|
||||
/// Additional qualifier
|
||||
pub qualifier: LabelQualifier,
|
||||
/// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos")
|
||||
pub codec_hint: String,
|
||||
/// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
|
||||
pub variant: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum StreamLabelType {
|
||||
Audio,
|
||||
Subtitle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LabelPurpose {
|
||||
Normal,
|
||||
Commentary,
|
||||
Descriptive,
|
||||
Score,
|
||||
Ime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LabelQualifier {
|
||||
None,
|
||||
Sdh,
|
||||
DescriptiveService,
|
||||
Forced,
|
||||
}
|
||||
|
||||
// ── Parser registry ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each entry: (name, detect_fn, parse_fn)
|
||||
// Order = priority. First match wins. Highest quality output first.
|
||||
|
||||
type DetectFn = fn(&UdfFs) -> bool;
|
||||
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
|
||||
|
||||
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
("paramount", paramount::detect, paramount::parse),
|
||||
("criterion", criterion::detect, criterion::parse),
|
||||
("pixelogic", pixelogic::detect, pixelogic::parse),
|
||||
("ctrm", ctrm::detect, ctrm::parse),
|
||||
// ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser
|
||||
];
|
||||
|
||||
/// Search disc for config files, extract labels, apply to streams.
|
||||
/// This is 100% optional — if anything fails, streams are untouched.
|
||||
pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
|
||||
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
extract(session, udf)
|
||||
})).unwrap_or_default();
|
||||
if labels.is_empty() { return; }
|
||||
|
||||
for title in titles.iter_mut() {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for stream in &mut title.streams {
|
||||
match stream {
|
||||
Stream::Audio(a) => {
|
||||
audio_idx += 1;
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
|
||||
) {
|
||||
let mut parts = Vec::new();
|
||||
match label.purpose {
|
||||
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
|
||||
LabelPurpose::Descriptive => parts.push("Descriptive Audio".to_string()),
|
||||
LabelPurpose::Score => parts.push("Score".to_string()),
|
||||
LabelPurpose::Ime => parts.push("IME".to_string()),
|
||||
LabelPurpose::Normal => {}
|
||||
}
|
||||
if !label.variant.is_empty() {
|
||||
parts.push(format!("({})", label.variant));
|
||||
}
|
||||
if !label.codec_hint.is_empty() {
|
||||
parts.push(label.codec_hint.clone());
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
} else if !label.name.is_empty() {
|
||||
a.label = label.name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Stream::Subtitle(s) => {
|
||||
sub_idx += 1;
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
|
||||
) {
|
||||
if label.qualifier == LabelQualifier::Forced {
|
||||
s.forced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
for (_name, detect, parse) in PARSERS {
|
||||
if detect(udf) {
|
||||
if let Some(labels) = parse(session, udf) {
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a file exists in any BDMV/JAR subdirectory.
|
||||
pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool {
|
||||
find_jar_file(udf, filename).is_some()
|
||||
}
|
||||
|
||||
/// Find a file in any BDMV/JAR subdirectory, return its path.
|
||||
pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
|
||||
let jar_dir = udf.find_dir("/BDMV/JAR")?;
|
||||
for entry in &jar_dir.entries {
|
||||
if entry.is_dir {
|
||||
let path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
|
||||
// Check if file exists in this subdirectory
|
||||
for child in &entry.entries {
|
||||
if !child.is_dir && child.name.eq_ignore_ascii_case(filename) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a file from any BDMV/JAR subdirectory by filename.
|
||||
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
|
||||
let path = find_jar_file(udf, filename)?;
|
||||
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Paramount/onQ — `playlists.xml`
|
||||
//!
|
||||
//! Richest structured format. Complete language lists with forced flags
|
||||
//! and commentary indices per playlist, all in XML attributes.
|
||||
//!
|
||||
//! ```xml
|
||||
//! <playlist name="Feature" id="00222"
|
||||
//! aud="eng,deu,spa,spa,fra"
|
||||
//! sub="eng,eng,zho,ces,dan"
|
||||
//! forced_sub="0,0,0,1,0"
|
||||
//! aud_com1_idx="10"
|
||||
//! sub_com1_idx="23,24,25" />
|
||||
//! ```
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "playlists.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "playlists.xml")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Find the feature playlist — longest duration or name="Feature"
|
||||
let feature = find_feature_playlist(text)?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
// Parse audio streams
|
||||
if let Some(aud) = extract_attr(&feature, "aud") {
|
||||
let com_idx = extract_attr(&feature, "aud_com1_idx")
|
||||
.and_then(|s| s.parse::<usize>().ok());
|
||||
|
||||
for (i, lang) in aud.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() { continue; }
|
||||
let purpose = if com_idx == Some(i) {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_type: StreamLabelType::Audio,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse subtitle streams
|
||||
if let Some(sub) = extract_attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = extract_attr(&feature, "forced_sub")
|
||||
.map(|s| s.split(',').map(|f| f.trim() == "1").collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let com_indices: Vec<usize> = extract_attr(&feature, "sub_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for (i, lang) in sub.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() { continue; }
|
||||
|
||||
let purpose = if com_indices.contains(&i) {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
let qualifier = if forced.get(i).copied().unwrap_or(false) {
|
||||
LabelQualifier::Forced
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_type: StreamLabelType::Subtitle,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
/// Find the feature playlist element (the one with the most audio tracks).
|
||||
fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
let mut best: Option<String> = None;
|
||||
let mut best_aud_count = 0;
|
||||
|
||||
let mut pos = 0;
|
||||
while let Some(start) = xml[pos..].find("<playlist ") {
|
||||
let abs_start = pos + start;
|
||||
let end = match xml[abs_start..].find("/>") {
|
||||
Some(p) => abs_start + p + 2,
|
||||
None => break,
|
||||
};
|
||||
let element = &xml[abs_start..end];
|
||||
|
||||
// Prefer name="Feature" explicitly
|
||||
if let Some(name) = extract_attr(element, "name") {
|
||||
if name.eq_ignore_ascii_case("Feature") {
|
||||
return Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise pick the one with the most audio streams
|
||||
if let Some(aud) = extract_attr(element, "aud") {
|
||||
let count = aud.split(',').count();
|
||||
if count > best_aud_count {
|
||||
best_aud_count = count;
|
||||
best = Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pos = end;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Extract an XML attribute value from an element string.
|
||||
fn extract_attr<'a>(element: &'a str, name: &str) -> Option<String> {
|
||||
let needle = format!("{}=\"", name);
|
||||
let start = element.find(&needle)? + needle.len();
|
||||
let end = element[start..].find('"')? + start;
|
||||
Some(element[start..end].to_string())
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Pixelogic — `bluray_project.bin`
|
||||
//!
|
||||
//! Binary file with embedded UTF-8 token strings in STN order per
|
||||
//! playlist section. Most common format (5/10 test discs).
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
|
||||
/// Known audio codec tokens
|
||||
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
|
||||
/// Known region tokens
|
||||
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"];
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "bluray_project.bin")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
|
||||
let strings = extract_strings(&data);
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut in_feature = false;
|
||||
let mut audio_num: u16 = 0;
|
||||
let mut sub_num: u16 = 0;
|
||||
|
||||
for s in &strings {
|
||||
// Detect feature section start
|
||||
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
|
||||
if in_feature { break; }
|
||||
in_feature = true;
|
||||
audio_num = 0;
|
||||
sub_num = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect section end
|
||||
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
|
||||
break;
|
||||
}
|
||||
|
||||
if !in_feature { continue; }
|
||||
|
||||
if let Some(label) = parse_token(s) {
|
||||
match label.stream_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_num += 1;
|
||||
labels.push(StreamLabel { stream_number: audio_num, ..label });
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_num += 1;
|
||||
labels.push(StreamLabel { stream_number: sub_num, ..label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.split('_').collect();
|
||||
if parts.len() < 2 { return None; }
|
||||
|
||||
let lang = parts[0];
|
||||
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut codec = String::new();
|
||||
let mut purpose = LabelPurpose::Normal;
|
||||
let mut qualifier = LabelQualifier::None;
|
||||
let mut variant = String::new();
|
||||
let mut is_subtitle = false;
|
||||
let mut is_audio = false;
|
||||
|
||||
for &part in &parts[1..] {
|
||||
if part.is_empty() { continue; }
|
||||
if AUDIO_CODECS.contains(&part) { codec = vocab::codec(part).to_string(); is_audio = true; }
|
||||
else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
|
||||
else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; }
|
||||
else if part == "ADLG" { is_audio = true; }
|
||||
else if part == "ATRI" { is_audio = true; }
|
||||
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
|
||||
else if part == "SDLG" { is_subtitle = true; }
|
||||
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
|
||||
else if part == "STRI" { is_subtitle = true; }
|
||||
else if part == "TXT" { is_subtitle = true; }
|
||||
else if part == "FOR" { qualifier = LabelQualifier::Forced; }
|
||||
else if REGIONS.contains(&part) { variant = part.to_string(); }
|
||||
else if part.starts_with("PGStream") { is_subtitle = true; }
|
||||
else { return None; }
|
||||
}
|
||||
|
||||
if !is_audio && !is_subtitle { return None; }
|
||||
|
||||
let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
|
||||
|
||||
Some(StreamLabel {
|
||||
stream_number: 0,
|
||||
stream_type,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: codec,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_strings(data: &[u8]) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for &b in data {
|
||||
if b >= 0x20 && b < 0x7f {
|
||||
current.push(b as char);
|
||||
} else {
|
||||
if current.len() > 3 {
|
||||
strings.push(current.clone());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() > 3 {
|
||||
strings.push(current);
|
||||
}
|
||||
strings
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Shared label vocabulary — values we are 100% confident about.
|
||||
//!
|
||||
//! Labels come from BD-J authoring tool files (bluray_project.bin,
|
||||
//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields.
|
||||
//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly.
|
||||
//!
|
||||
//! Rules:
|
||||
//! - Only map values we are 100% certain about (published codec names).
|
||||
//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc.
|
||||
//! - The app/CLI handles display text, not the lib.
|
||||
|
||||
/// Map a codec identifier found in label data to its display name.
|
||||
///
|
||||
/// These are well-known codec identifiers used across multiple BD-J
|
||||
/// authoring tools. NOT BD spec STN codec IDs — those are decoded
|
||||
/// separately in mpls.rs.
|
||||
pub fn codec(code: &str) -> &str {
|
||||
match code {
|
||||
"MLP" => "TrueHD",
|
||||
"AC3" | "AC" => "Dolby Digital",
|
||||
"DTS" => "DTS",
|
||||
"DDL" => "Dolby Digital Plus",
|
||||
"WAV" => "PCM",
|
||||
"atmos" => "Dolby Atmos",
|
||||
_ => code,
|
||||
}
|
||||
}
|
||||
+62
-26
@@ -1,38 +1,70 @@
|
||||
//! libfreemkv — Open source raw disc access for optical drives.
|
||||
//! libfreemkv — Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//!
|
||||
//! Provides SCSI/MMC commands to enable raw reading mode on compatible
|
||||
//! Blu-ray drives, allowing direct sector access for disc archival
|
||||
//! and backup purposes.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The library is data-driven. Drive-specific SCSI command sequences
|
||||
//! are stored in profile files, not in code. Adding support for a new
|
||||
//! drive requires only a profile contribution — no rebuild needed.
|
||||
//!
|
||||
//! ```text
|
||||
//! DriveSession (high-level API)
|
||||
//! ├── Platform trait (per-chipset unlock logic)
|
||||
//! ├── DriveProfile (per-drive data from JSON profiles)
|
||||
//! └── ScsiTransport (SG_IO on Linux, IOKit on macOS)
|
||||
//! ```
|
||||
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
||||
//! sector reading. 206 bundled drive profiles. No external files needed.
|
||||
//!
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use libfreemkv::DriveSession;
|
||||
//! use libfreemkv::{DriveSession, Disc, ScanOptions};
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let mut session = DriveSession::open(
|
||||
//! Path::new("/dev/sr0"),
|
||||
//! ).unwrap();
|
||||
//! let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
//!
|
||||
//! session.unlock().unwrap();
|
||||
//! session.calibrate().unwrap();
|
||||
//! for title in &disc.titles {
|
||||
//! println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
//! }
|
||||
//!
|
||||
//! let mut buf = vec![0u8; 2048];
|
||||
//! let n = session.read_sectors(0, 1, &mut buf).unwrap();
|
||||
//! // Read content (decrypted automatically if AACS keys available)
|
||||
//! let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
//! while let Some(unit) = reader.read_unit().unwrap() {
|
||||
//! // 6144 bytes of decrypted content per unit
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! DriveSession — open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveProfile — per-drive unlock parameters (206 bundled)
|
||||
//! ├── DriveId — INQUIRY + GET_CONFIG identification
|
||||
//! └── Platform
|
||||
//! └── Mt1959 — MediaTek unlock/read (Renesas planned)
|
||||
//!
|
||||
//! Disc — scan titles, streams, AACS state
|
||||
//! ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
//! ├── MPLS parser — playlists → titles + clips + STN streams
|
||||
//! ├── CLPI parser — clip info → EP map → sector extents
|
||||
//! ├── JAR parser — BD-J audio track labels
|
||||
//! └── AACS — encryption: key resolution + content decrypt
|
||||
//! ├── aacs — KEYDB, VUK, MKB, unit decrypt
|
||||
//! └── handshake — SCSI auth, ECDH, bus key
|
||||
//! ```
|
||||
//!
|
||||
//! # AACS Encryption
|
||||
//!
|
||||
//! Disc scanning automatically detects and handles AACS encryption.
|
||||
//! If a KEYDB.cfg is available (via `ScanOptions` or standard paths),
|
||||
//! the library resolves keys and decrypts content transparently.
|
||||
//!
|
||||
//! Supports AACS 1.0 (Blu-ray) and AACS 2.0 (UHD, with fallback).
|
||||
//!
|
||||
//! # Error Codes
|
||||
//!
|
||||
//! All errors are structured with numeric codes. No user-facing English
|
||||
//! text — applications format their own messages.
|
||||
//!
|
||||
//! | Range | Category |
|
||||
//! |-------|----------|
|
||||
//! | E1xxx | Device errors (not found, permission) |
|
||||
//! | E2xxx | Profile errors (unsupported drive) |
|
||||
//! | E3xxx | Unlock errors (failed, signature) |
|
||||
//! | E4xxx | SCSI errors (command failed, timeout) |
|
||||
//! | E5xxx | I/O errors |
|
||||
//! | E6xxx | Disc format errors |
|
||||
//! | E7xxx | AACS errors |
|
||||
|
||||
pub mod error;
|
||||
pub mod scsi;
|
||||
@@ -45,6 +77,8 @@ pub mod udf;
|
||||
pub mod mpls;
|
||||
pub mod clpi;
|
||||
pub mod disc;
|
||||
pub mod aacs;
|
||||
pub mod labels;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use drive::DriveSession;
|
||||
@@ -53,4 +87,6 @@ pub use profile::{DriveProfile, Chipset};
|
||||
pub use platform::{Platform, DriveStatus};
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use speed::DriveSpeed;
|
||||
pub use disc::{Disc, Title, Stream, StreamKind, Codec, HdrFormat, ColorSpace, Extent};
|
||||
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
Codec, HdrFormat, ColorSpace,
|
||||
Extent, ContentReader, AacsState, KeySource, ScanOptions};
|
||||
|
||||
+203
-44
@@ -15,10 +15,8 @@ pub struct Playlist {
|
||||
pub version: String,
|
||||
/// Play items in playback order
|
||||
pub play_items: Vec<PlayItem>,
|
||||
/// Number of video streams
|
||||
pub video_stream_count: u16,
|
||||
/// Number of audio streams
|
||||
pub audio_stream_count: u16,
|
||||
/// Streams from the first play item's STN table
|
||||
pub streams: Vec<StreamEntry>,
|
||||
}
|
||||
|
||||
/// A play item — one clip reference with in/out times.
|
||||
@@ -34,73 +32,155 @@ pub struct PlayItem {
|
||||
pub connection_condition: u8,
|
||||
}
|
||||
|
||||
/// A stream entry from the STN table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamEntry {
|
||||
/// Stream category: 1=video, 2=audio, 3=PG subtitle, 4=IG, 5=secondary audio, 6=secondary video, 7=DV EL
|
||||
pub stream_type: u8,
|
||||
/// MPEG-TS PID
|
||||
pub pid: u16,
|
||||
/// Coding type (0x24=HEVC, 0x1B=H264, 0x83=TrueHD, etc.)
|
||||
pub coding_type: u8,
|
||||
/// Video format (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p)
|
||||
pub video_format: u8,
|
||||
/// Video frame rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94)
|
||||
pub video_rate: u8,
|
||||
/// Audio channel layout (1=mono, 3=stereo, 6=5.1, 12=7.1)
|
||||
pub audio_format: u8,
|
||||
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz)
|
||||
pub audio_rate: u8,
|
||||
/// ISO 639-2 language code (e.g. "eng")
|
||||
pub language: String,
|
||||
/// HDR dynamic range (0=SDR, 1=HDR10, 2=Dolby Vision)
|
||||
pub dynamic_range: u8,
|
||||
/// Color space (0=unknown, 1=BT.709, 2=BT.2020)
|
||||
pub color_space: u8,
|
||||
/// Whether this is a secondary stream (commentary, PiP, DV EL)
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
/// Parse an MPLS file from raw bytes.
|
||||
pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
if data.len() < 40 {
|
||||
return Err(Error::DiscError { detail: "MPLS too short".into() });
|
||||
}
|
||||
|
||||
// Header: "MPLS" + version (4 bytes ASCII)
|
||||
if &data[0..4] != b"MPLS" {
|
||||
return Err(Error::DiscError { detail: "not an MPLS file".into() });
|
||||
}
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
|
||||
// Offsets table at bytes 8-19
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
let _playlist_mark_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
|
||||
if playlist_start + 10 > data.len() {
|
||||
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
|
||||
}
|
||||
|
||||
// PlayList section
|
||||
let pl = &data[playlist_start..];
|
||||
let _pl_length = u32::from_be_bytes([pl[0], pl[1], pl[2], pl[3]]) as usize;
|
||||
// pl[4..6] reserved
|
||||
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
|
||||
let _num_sub_paths = u16::from_be_bytes([pl[8], pl[9]]) as usize;
|
||||
|
||||
let mut play_items = Vec::with_capacity(num_play_items);
|
||||
let mut pos = 10; // start of first play item
|
||||
|
||||
let mut video_streams: u16 = 0;
|
||||
let mut audio_streams: u16 = 0;
|
||||
|
||||
for _ in 0..num_play_items {
|
||||
if playlist_start + pos + 2 > data.len() {
|
||||
break;
|
||||
}
|
||||
let mut streams = Vec::new();
|
||||
let mut pos = 10;
|
||||
|
||||
for item_idx in 0..num_play_items {
|
||||
if pos + 2 > pl.len() { break; }
|
||||
let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize;
|
||||
if playlist_start + pos + item_length + 2 > data.len() {
|
||||
break;
|
||||
}
|
||||
if pos + 2 + item_length > pl.len() { break; }
|
||||
|
||||
let item = &pl[pos + 2..pos + 2 + item_length];
|
||||
if item.len() < 20 { pos += 2 + item_length; continue; }
|
||||
|
||||
// Clip ID: 5 bytes ASCII at offset 0 (e.g. "00001")
|
||||
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
|
||||
// item[5..9] = codec ID ("M2TS")
|
||||
// item[9] = connection condition (bits)
|
||||
let connection_condition = item[9] & 0x0F;
|
||||
// item[10] = ref to STC_id
|
||||
// item[12..16] = IN_time
|
||||
let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]);
|
||||
// item[16..20] = OUT_time
|
||||
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
|
||||
|
||||
// STN table follows at offset 20 within the play item
|
||||
if item.len() > 22 {
|
||||
let stn_length = u16::from_be_bytes([item[20], item[21]]) as usize;
|
||||
if stn_length > 4 && item.len() > 24 {
|
||||
// Number of primary video/audio entries
|
||||
let n_video = item[24] as u16;
|
||||
let n_audio = item[25] as u16;
|
||||
if video_streams == 0 {
|
||||
video_streams = n_video;
|
||||
audio_streams = n_audio;
|
||||
}
|
||||
// Parse STN table from the first play item
|
||||
// PlayItem layout after out_time:
|
||||
// [20:28] UO_mask_table (8 bytes)
|
||||
// [28] misc flags (1 byte)
|
||||
// [29] still_mode (1 byte)
|
||||
// [30:32] still_time (2 bytes)
|
||||
// [32:] STN_table
|
||||
const STN_OFFSET: usize = 32;
|
||||
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
|
||||
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
|
||||
let n_video = item[STN_OFFSET + 4] as usize;
|
||||
let n_audio = item[STN_OFFSET + 5] as usize;
|
||||
let n_pg = item[STN_OFFSET + 6] as usize;
|
||||
let n_ig = item[STN_OFFSET + 7] as usize;
|
||||
let n_sec_audio = item[STN_OFFSET + 8] as usize;
|
||||
let n_sec_video = item[STN_OFFSET + 9] as usize;
|
||||
let _n_pip_pg = item[STN_OFFSET + 10] as usize;
|
||||
let n_dv = item[STN_OFFSET + 11] as usize;
|
||||
|
||||
let mut spos = STN_OFFSET + 16;
|
||||
|
||||
// Primary video
|
||||
for _ in 0..n_video {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// Primary audio
|
||||
for _ in 0..n_audio {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 2) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// PG subtitles
|
||||
for _ in 0..n_pg {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 3) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// IG (skip but advance)
|
||||
for _ in 0..n_ig {
|
||||
if let Some((_, next)) = parse_stream_entry(item, spos, 4) {
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// Secondary audio
|
||||
for _ in 0..n_sec_audio {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 2) {
|
||||
entry.stream_type = 5;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip extra ref bytes: num_refs(1) + reserved(1) + refs + padding
|
||||
if next < item.len() {
|
||||
let n_refs = item[next] as usize;
|
||||
spos = next + 2 + n_refs + (n_refs % 2);
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Secondary video (PiP)
|
||||
for _ in 0..n_sec_video {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
entry.stream_type = 6;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip extra ref bytes (audio refs + PG refs)
|
||||
if next + 2 < item.len() {
|
||||
let n_arefs = item[next] as usize;
|
||||
let after_arefs = next + 2 + n_arefs + (n_arefs % 2);
|
||||
if after_arefs < item.len() {
|
||||
let n_prefs = item[after_arefs] as usize;
|
||||
spos = after_arefs + 2 + n_prefs + (n_prefs % 2);
|
||||
} else { spos = after_arefs; }
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Dolby Vision enhancement layer
|
||||
for _ in 0..n_dv {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
entry.stream_type = 7;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +197,86 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
Ok(Playlist {
|
||||
version,
|
||||
play_items,
|
||||
video_stream_count: video_streams,
|
||||
audio_stream_count: audio_streams,
|
||||
streams,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one stream entry from the STN table.
|
||||
/// Returns (StreamEntry, next position) or None.
|
||||
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
|
||||
if pos + 2 > item.len() { return None; }
|
||||
|
||||
// Stream entry: length(1) + data
|
||||
let se_len = item[pos] as usize;
|
||||
let se_end = pos + 1 + se_len;
|
||||
if se_end > item.len() { return None; }
|
||||
|
||||
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
|
||||
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
|
||||
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Stream attributes: length(1) + coding_type(1) + format-specific data
|
||||
if se_end + 2 > item.len() { return None; }
|
||||
let sa_len = item[se_end] as usize;
|
||||
let sa_end = se_end + 1 + sa_len;
|
||||
if sa_end > item.len() || sa_len < 1 { return None; }
|
||||
|
||||
let sa = &item[se_end + 1..se_end + 1 + sa_len];
|
||||
let coding_type = sa[0];
|
||||
|
||||
let mut video_format = 0u8;
|
||||
let mut video_rate = 0u8;
|
||||
let mut audio_format = 0u8;
|
||||
let mut audio_rate = 0u8;
|
||||
let mut dynamic_range = 0u8;
|
||||
let mut color_space_val = 0u8;
|
||||
let mut language = String::new();
|
||||
|
||||
match stream_type {
|
||||
1 => {
|
||||
// Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC]
|
||||
if sa.len() >= 2 {
|
||||
video_format = (sa[1] >> 4) & 0x0F;
|
||||
video_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if coding_type == 0x24 && sa.len() > 2 {
|
||||
dynamic_range = (sa[2] >> 4) & 0x0F;
|
||||
color_space_val = sa[2] & 0x0F;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Audio: coding_type(1) + format_rate(1) + language(3)
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if sa.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
3 | 4 => {
|
||||
// PG/IG: coding_type(1) + language(3)
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some((StreamEntry {
|
||||
stream_type,
|
||||
pid,
|
||||
coding_type,
|
||||
video_format,
|
||||
video_rate,
|
||||
audio_format,
|
||||
audio_rate,
|
||||
language,
|
||||
dynamic_range,
|
||||
color_space: color_space_val,
|
||||
secondary: false,
|
||||
}, sa_end))
|
||||
}
|
||||
|
||||
-248
@@ -1,248 +0,0 @@
|
||||
//! SCSI/MMC command interface.
|
||||
//!
|
||||
//! Platform backends:
|
||||
//! - Linux: SG_IO ioctl
|
||||
//! - macOS: IOKit SCSI passthrough (planned)
|
||||
//! - Windows: SPTI (planned)
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DataDirection {
|
||||
None,
|
||||
FromDevice,
|
||||
ToDevice,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScsiResult {
|
||||
pub status: u8,
|
||||
pub bytes_transferred: usize,
|
||||
pub sense: [u8; 32],
|
||||
}
|
||||
|
||||
/// Low-level SCSI transport — implemented per platform.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
|
||||
// ─── Linux: SG_IO ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_IO: u32 = 0x2285;
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_DXFER_NONE: i32 = -1;
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_DXFER_TO_DEV: i32 = -2;
|
||||
#[cfg(target_os = "linux")]
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
struct sg_io_hdr {
|
||||
interface_id: i32,
|
||||
dxfer_direction: i32,
|
||||
cmd_len: u8,
|
||||
mx_sb_len: u8,
|
||||
iovec_count: u16,
|
||||
dxfer_len: u32,
|
||||
dxferp: *mut u8,
|
||||
cmdp: *const u8,
|
||||
sbp: *mut u8,
|
||||
timeout: u32,
|
||||
flags: u32,
|
||||
pack_id: i32,
|
||||
usr_ptr: *mut libc::c_void,
|
||||
status: u8,
|
||||
masked_status: u8,
|
||||
msg_status: u8,
|
||||
sb_len_wr: u8,
|
||||
host_status: u16,
|
||||
driver_status: u16,
|
||||
resid: i32,
|
||||
duration: u32,
|
||||
info: u32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl SgIoTransport {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
|
||||
let fd = unsafe { libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK) };
|
||||
if fd < 0 {
|
||||
return Err(Error::DeviceNotFound { path: device.display().to_string() });
|
||||
}
|
||||
Ok(SgIoTransport { fd })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd); }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl ScsiTransport for SgIoTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let mut sense = [0u8; 32];
|
||||
|
||||
let dxfer_direction = match direction {
|
||||
DataDirection::None => SG_DXFER_NONE,
|
||||
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
|
||||
DataDirection::ToDevice => SG_DXFER_TO_DEV,
|
||||
};
|
||||
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = dxfer_direction;
|
||||
hdr.cmd_len = cdb.len() as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = data.len() as u32;
|
||||
hdr.dxferp = data.as_mut_ptr();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
|
||||
let ret = unsafe {
|
||||
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
|
||||
};
|
||||
|
||||
if ret < 0 {
|
||||
return Err(Error::IoError { source: std::io::Error::last_os_error() });
|
||||
}
|
||||
|
||||
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
|
||||
|
||||
if hdr.status != 0 {
|
||||
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: hdr.status,
|
||||
bytes_transferred,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── macOS: IOKit (planned) ─────────────────────────────────────────────────
|
||||
|
||||
// TODO: IOKit MMC SCSI passthrough
|
||||
// Use IOSCSIPeripheralDeviceType05 (MMC device nub)
|
||||
// Send SCSITaskInterface commands via IOKit user client
|
||||
|
||||
// ─── Windows: SPTI (planned) ────────────────────────────────────────────────
|
||||
|
||||
// TODO: SCSI Pass Through Interface
|
||||
// Use CreateFile on \\.\CdRomN
|
||||
// Send IOCTL_SCSI_PASS_THROUGH_DIRECT
|
||||
|
||||
// ─── Platform-agnostic open ─────────────────────────────────────────────────
|
||||
|
||||
/// Open a SCSI transport for the given device path.
|
||||
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ Ok(Box::new(SgIoTransport::open(device)?)) }
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ Err(Error::DeviceNotFound { path: format!("{}: platform not yet supported (Linux only)", device.display()) }) }
|
||||
}
|
||||
|
||||
// ─── CDB builders (platform-agnostic) ───────────────────────────────────────
|
||||
|
||||
/// SCSI INQUIRY response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InquiryResult {
|
||||
pub vendor_id: String,
|
||||
pub model: String,
|
||||
pub firmware: String,
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Send INQUIRY command and parse the standard response fields.
|
||||
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
|
||||
let cdb = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||
let mut buf = [0u8; 96];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
|
||||
let vendor = String::from_utf8_lossy(&buf[8..16]).trim().to_string();
|
||||
let model = String::from_utf8_lossy(&buf[16..32]).trim().to_string();
|
||||
let firmware = String::from_utf8_lossy(&buf[32..36]).trim().to_string();
|
||||
|
||||
Ok(InquiryResult {
|
||||
vendor_id: vendor,
|
||||
model,
|
||||
firmware,
|
||||
raw: buf.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
|
||||
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let cdb = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut buf = [0u8; 16];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
Ok(buf.to_vec())
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER (0x3C) CDB.
|
||||
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
|
||||
[
|
||||
0x3C, mode, buffer_id,
|
||||
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
|
||||
(length >> 16) as u8, (length >> 8) as u8, length as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a SET CD SPEED (0xBB) CDB.
|
||||
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
[
|
||||
0xBB, 0x00,
|
||||
(read_speed >> 8) as u8, read_speed as u8,
|
||||
0xFF, 0xFF,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a READ(10) CDB with the raw read flag (0x08).
|
||||
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
|
||||
[
|
||||
0x28, 0x08,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Linux SCSI transport via SG_IO ioctl.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use super::{ScsiTransport, ScsiResult, DataDirection};
|
||||
use std::path::Path;
|
||||
|
||||
const SG_IO: u32 = 0x2285;
|
||||
const SG_DXFER_NONE: i32 = -1;
|
||||
const SG_DXFER_TO_DEV: i32 = -2;
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
struct sg_io_hdr {
|
||||
interface_id: i32,
|
||||
dxfer_direction: i32,
|
||||
cmd_len: u8,
|
||||
mx_sb_len: u8,
|
||||
iovec_count: u16,
|
||||
dxfer_len: u32,
|
||||
dxferp: *mut u8,
|
||||
cmdp: *const u8,
|
||||
sbp: *mut u8,
|
||||
timeout: u32,
|
||||
flags: u32,
|
||||
pack_id: i32,
|
||||
usr_ptr: *mut libc::c_void,
|
||||
status: u8,
|
||||
masked_status: u8,
|
||||
msg_status: u8,
|
||||
sb_len_wr: u8,
|
||||
host_status: u16,
|
||||
driver_status: u16,
|
||||
resid: i32,
|
||||
duration: u32,
|
||||
info: u32,
|
||||
}
|
||||
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
}
|
||||
|
||||
impl SgIoTransport {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_bytes = device.as_os_str().as_bytes();
|
||||
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
|
||||
c_path.extend_from_slice(path_bytes);
|
||||
c_path.push(0);
|
||||
|
||||
let fd = unsafe {
|
||||
libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(Error::DeviceNotFound { path: device.display().to_string() });
|
||||
}
|
||||
Ok(SgIoTransport { fd })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd); }
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for SgIoTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let mut sense = [0u8; 32];
|
||||
|
||||
let dxfer_direction = match direction {
|
||||
DataDirection::None => SG_DXFER_NONE,
|
||||
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
|
||||
DataDirection::ToDevice => SG_DXFER_TO_DEV,
|
||||
};
|
||||
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = dxfer_direction;
|
||||
hdr.cmd_len = cdb.len() as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = data.len() as u32;
|
||||
hdr.dxferp = data.as_mut_ptr();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
|
||||
let ret = unsafe {
|
||||
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
|
||||
};
|
||||
|
||||
if ret < 0 {
|
||||
return Err(Error::IoError { source: std::io::Error::last_os_error() });
|
||||
}
|
||||
|
||||
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
|
||||
|
||||
if hdr.status != 0 {
|
||||
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: hdr.status,
|
||||
bytes_transferred,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! macOS SCSI transport via IOKit SCSITaskDeviceInterface.
|
||||
//!
|
||||
//! Sends SCSI commands to optical drives through IOKit's SCSI Architecture
|
||||
//! Model family. Accepts BSD device paths like `/dev/disk2` or `/dev/rdisk2`.
|
||||
//!
|
||||
//! Requires exclusive access to the device — unmount the disc first:
|
||||
//! `diskutil unmountDisk /dev/disk2`
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||
use std::path::Path;
|
||||
|
||||
// ── IOKit / CoreFoundation type aliases ─────────────────────────────────────
|
||||
|
||||
type CFMutableDictionaryRef = *mut std::ffi::c_void;
|
||||
type IOObject = u32;
|
||||
type IOReturn = i32;
|
||||
type MachPort = u32;
|
||||
|
||||
/// Opaque COM interface pointer — `*mut *mut VTable` (double-indirect).
|
||||
/// IOKit plugins use COM-style vtables: the pointer points to a pointer
|
||||
/// to the function table.
|
||||
type ComRef = *mut *mut std::ffi::c_void;
|
||||
|
||||
const K_IO_RETURN_SUCCESS: IOReturn = 0;
|
||||
|
||||
// SCSI data transfer directions (SCSITaskLib.h)
|
||||
const K_SCSI_DATA_TRANSFER_NO_DATA: u8 = 0;
|
||||
const K_SCSI_DATA_TRANSFER_FROM_TARGET: u8 = 1;
|
||||
const K_SCSI_DATA_TRANSFER_TO_TARGET: u8 = 2;
|
||||
|
||||
// SCSI task status values
|
||||
const K_SCSI_TASK_STATUS_GOOD: u8 = 0x00;
|
||||
|
||||
const K_MAX_CDB_SIZE: usize = 16;
|
||||
const K_SENSE_DATA_SIZE: usize = 32;
|
||||
|
||||
// ── IOKit plugin UUIDs ──────────────────────────────────────────────────────
|
||||
// From IOKit/scsi/SCSITaskLib.h
|
||||
|
||||
/// kIOMMCDeviceUserClientTypeID — plugin type for MMC (optical) devices.
|
||||
const K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
|
||||
0x97, 0xAB, 0xCF, 0x5C, 0x45, 0x71, 0x11, 0xD6,
|
||||
0xB6, 0xA0, 0x00, 0x30, 0x65, 0xA4, 0x7A, 0xEE,
|
||||
];
|
||||
|
||||
/// kIOCFPlugInInterfaceID — base IOCFPlugin interface.
|
||||
const K_IO_CFPLUGIN_INTERFACE_ID: [u8; 16] = [
|
||||
0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4,
|
||||
0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F,
|
||||
];
|
||||
|
||||
/// kIOSCSITaskDeviceInterfaceID — the interface we QueryInterface for.
|
||||
const K_IO_SCSI_TASK_DEVICE_INTERFACE_ID: [u8; 16] = [
|
||||
0x61, 0x3E, 0x48, 0xB0, 0x30, 0x01, 0x11, 0xD6,
|
||||
0xA4, 0xC0, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
|
||||
];
|
||||
|
||||
// ── Scatter/gather element ──────────────────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
struct SCSITaskSGElement {
|
||||
address: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
// ── External IOKit / CoreFoundation functions ───────────────────────────────
|
||||
|
||||
extern "C" {
|
||||
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
|
||||
fn IOBSDNameMatching(
|
||||
master: MachPort,
|
||||
options: u32,
|
||||
bsd_name: *const u8,
|
||||
) -> CFMutableDictionaryRef;
|
||||
fn IOServiceGetMatchingService(
|
||||
master: MachPort,
|
||||
matching: CFMutableDictionaryRef,
|
||||
) -> IOObject;
|
||||
fn IOObjectRelease(object: IOObject) -> IOReturn;
|
||||
fn IORegistryEntryGetParentEntry(
|
||||
entry: IOObject,
|
||||
plane: *const u8,
|
||||
parent: *mut IOObject,
|
||||
) -> IOReturn;
|
||||
fn IOObjectConformsTo(object: IOObject, class_name: *const u8) -> u8;
|
||||
fn IOCreatePlugInInterfaceForService(
|
||||
service: IOObject,
|
||||
plugin_type: *const [u8; 16],
|
||||
interface_type: *const [u8; 16],
|
||||
the_interface: *mut ComRef,
|
||||
the_score: *mut i32,
|
||||
) -> IOReturn;
|
||||
}
|
||||
|
||||
// ── COM vtable helpers ──────────────────────────────────────────────────────
|
||||
//
|
||||
// IOKit plugin interfaces use COM-style vtables. A ComRef is **vtable —
|
||||
// dereferencing once gives the vtable pointer, then index into it for
|
||||
// individual function pointers.
|
||||
//
|
||||
// All vtable indices verified against Apple open source:
|
||||
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
|
||||
|
||||
/// Read a function pointer from a COM vtable at the given index.
|
||||
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
|
||||
let vtable = *iface as *const *const std::ffi::c_void;
|
||||
let fn_ptr = *vtable.add(index);
|
||||
std::mem::transmute_copy(&fn_ptr)
|
||||
}
|
||||
|
||||
/// Call Release (vtable index 3) on any COM interface.
|
||||
fn com_release(iface: ComRef) {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> u32;
|
||||
unsafe {
|
||||
let f: Fn = vtable_fn(iface, 3);
|
||||
f(iface);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCSITaskDeviceInterface vtable ──────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsExclusiveAccessAvailable
|
||||
// 5 AddCallbackDispatcherToRunLoop
|
||||
// 6 RemoveCallbackDispatcherFromRunLoop
|
||||
// 7 ObtainExclusiveAccess
|
||||
// 8 ReleaseExclusiveAccess
|
||||
// 9 CreateSCSITask
|
||||
|
||||
const VTIDX_OBTAIN_EXCLUSIVE: usize = 7;
|
||||
const VTIDX_RELEASE_EXCLUSIVE: usize = 8;
|
||||
const VTIDX_CREATE_TASK: usize = 9;
|
||||
|
||||
// ── SCSITaskInterface vtable ────────────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsTaskActive
|
||||
// 5 SetTaskAttribute
|
||||
// 6 GetTaskAttribute
|
||||
// 7 GetTaskState
|
||||
// 8 SetCommandDescriptorBlock
|
||||
// 9 GetCommandDescriptorBlockSize
|
||||
// 10 GetCommandDescriptorBlock
|
||||
// 11 SetScatterGatherEntries
|
||||
// 12 SetTimeoutDuration
|
||||
// 13 GetTimeoutDuration
|
||||
// 14 SetTaskCompletionCallback
|
||||
// 15 ExecuteTaskSync
|
||||
// 16 ExecuteTaskAsync
|
||||
// 17 AbortTask
|
||||
// 18 GetSCSIServiceResponse
|
||||
// 19 GetTaskStatus
|
||||
// 20 GetRealizedDataTransferCount
|
||||
// 21 GetAutoSenseData
|
||||
|
||||
const VTIDX_SET_CDB: usize = 8;
|
||||
const VTIDX_SET_SG: usize = 11;
|
||||
const VTIDX_SET_TIMEOUT: usize = 12;
|
||||
const VTIDX_EXECUTE_SYNC: usize = 15;
|
||||
|
||||
// ── Transport implementation ────────────────────────────────────────────────
|
||||
|
||||
pub struct MacScsiTransport {
|
||||
device_iface: ComRef,
|
||||
exclusive: bool,
|
||||
}
|
||||
|
||||
impl MacScsiTransport {
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Strip /dev/ prefix to get BSD name (e.g. "disk2")
|
||||
let bsd_name = if let Some(rest) = dev_str.strip_prefix("/dev/r") {
|
||||
rest
|
||||
} else if let Some(rest) = dev_str.strip_prefix("/dev/") {
|
||||
rest
|
||||
} else {
|
||||
dev_str
|
||||
};
|
||||
|
||||
let service = find_scsi_service(bsd_name)?;
|
||||
|
||||
// Create IOKit plugin for the MMC device
|
||||
let mut plugin: ComRef = std::ptr::null_mut();
|
||||
let mut score: i32 = 0;
|
||||
let kr = unsafe {
|
||||
IOCreatePlugInInterfaceForService(
|
||||
service,
|
||||
&K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID,
|
||||
&K_IO_CFPLUGIN_INTERFACE_ID,
|
||||
&mut plugin,
|
||||
&mut score,
|
||||
)
|
||||
};
|
||||
unsafe { IOObjectRelease(service) };
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: IOKit plugin creation failed (0x{:08x})", dev_str, kr),
|
||||
});
|
||||
}
|
||||
|
||||
// QueryInterface for SCSITaskDeviceInterface
|
||||
let mut device_iface: ComRef = std::ptr::null_mut();
|
||||
let hr = unsafe {
|
||||
type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32;
|
||||
let qi: QiFn = vtable_fn(plugin, 1);
|
||||
qi(plugin, &K_IO_SCSI_TASK_DEVICE_INTERFACE_ID, &mut device_iface)
|
||||
};
|
||||
com_release(plugin);
|
||||
|
||||
if hr != 0 || device_iface.is_null() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: SCSITaskDeviceInterface not available", dev_str),
|
||||
});
|
||||
}
|
||||
|
||||
// Obtain exclusive access
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(device_iface, VTIDX_OBTAIN_EXCLUSIVE);
|
||||
f(device_iface)
|
||||
};
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
com_release(device_iface);
|
||||
return Err(Error::DevicePermission {
|
||||
path: format!(
|
||||
"{}: exclusive access denied (0x{:08x}). Try: diskutil unmountDisk {}",
|
||||
dev_str, kr, dev_str
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MacScsiTransport {
|
||||
device_iface,
|
||||
exclusive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MacScsiTransport {
|
||||
fn drop(&mut self) {
|
||||
if self.exclusive {
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE);
|
||||
f(self.device_iface);
|
||||
}
|
||||
}
|
||||
com_release(self.device_iface);
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for MacScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
// Create a SCSI task
|
||||
let task: ComRef = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> ComRef;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_CREATE_TASK);
|
||||
f(self.device_iface)
|
||||
};
|
||||
if task.is_null() {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Set CDB
|
||||
let mut cdb_padded = [0u8; K_MAX_CDB_SIZE];
|
||||
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
|
||||
cdb_padded[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, *const u8, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_CDB);
|
||||
f(task, cdb_padded.as_ptr(), cdb_len as u8);
|
||||
}
|
||||
|
||||
// Set scatter/gather and transfer direction
|
||||
let iokit_dir = match direction {
|
||||
DataDirection::None => K_SCSI_DATA_TRANSFER_NO_DATA,
|
||||
DataDirection::FromDevice => K_SCSI_DATA_TRANSFER_FROM_TARGET,
|
||||
DataDirection::ToDevice => K_SCSI_DATA_TRANSFER_TO_TARGET,
|
||||
};
|
||||
|
||||
if direction != DataDirection::None && !data.is_empty() {
|
||||
let sg = SCSITaskSGElement {
|
||||
address: data.as_mut_ptr() as u64,
|
||||
length: data.len() as u64,
|
||||
};
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(
|
||||
ComRef, *const SCSITaskSGElement, u8, u64, u8,
|
||||
) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, &sg, 1, data.len() as u64, iokit_dir);
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(
|
||||
ComRef, *const SCSITaskSGElement, u8, u64, u8,
|
||||
) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, std::ptr::null(), 0, 0, K_SCSI_DATA_TRANSFER_NO_DATA);
|
||||
}
|
||||
}
|
||||
|
||||
// Set timeout (IOKit SCSITask takes milliseconds)
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, u32);
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_TIMEOUT);
|
||||
f(task, timeout_ms);
|
||||
}
|
||||
|
||||
// Execute synchronously
|
||||
let mut sense = [0u8; K_SENSE_DATA_SIZE];
|
||||
let mut task_status: u8 = 0;
|
||||
let mut realized_count: u64 = 0;
|
||||
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(
|
||||
ComRef, *mut u8, *mut u8, *mut u64,
|
||||
) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_EXECUTE_SYNC);
|
||||
f(task, sense.as_mut_ptr(), &mut task_status, &mut realized_count)
|
||||
};
|
||||
|
||||
com_release(task);
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if task_status != K_SCSI_TASK_STATUS_GOOD {
|
||||
let sense_key = if sense[2] != 0 { sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: task_status,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: task_status,
|
||||
bytes_transferred: realized_count as usize,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── IOKit service discovery ─────────────────────────────────────────────────
|
||||
|
||||
/// BSD name → IOKit service for the SCSI device.
|
||||
///
|
||||
/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub.
|
||||
fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
|
||||
let mut master: MachPort = 0;
|
||||
let kr = unsafe { IOMasterPort(0, &mut master) };
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: IOMasterPort failed", bsd_name),
|
||||
});
|
||||
}
|
||||
|
||||
// IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name }
|
||||
let mut bsd_c = bsd_name.as_bytes().to_vec();
|
||||
bsd_c.push(0);
|
||||
let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) };
|
||||
if matching.is_null() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: IOBSDNameMatching failed", bsd_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Find the single IOMedia service (consumes the matching dict)
|
||||
let media = unsafe { IOServiceGetMatchingService(master, matching) };
|
||||
if media == 0 {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: no IOMedia found", bsd_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Walk up the IOService plane to find the authoring device.
|
||||
// The chain is typically:
|
||||
// IOMedia → IOPartitionScheme → IOMedia → IOBlockStorageDriver
|
||||
// → IOSCSIPeripheralDeviceNub (this is what we want)
|
||||
//
|
||||
// We walk up until we find a service that IOCreatePlugInInterfaceForService
|
||||
// accepts with kIOMMCDeviceUserClientTypeID, or until we hit the root.
|
||||
let service = walk_to_authoring_device(media);
|
||||
unsafe { IOObjectRelease(media) };
|
||||
|
||||
service.ok_or_else(|| Error::DeviceNotFound {
|
||||
path: format!("{}: no SCSI authoring device in IORegistry", bsd_name),
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk up the IOService plane from an IOMedia to the SCSI authoring device.
|
||||
fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
|
||||
let mut current = start;
|
||||
// Retain start so we can release uniformly in the loop
|
||||
// (IORegistryEntryGetParentEntry retains the parent for us)
|
||||
|
||||
// Target class names for authoring devices
|
||||
let target_classes: &[&[u8]] = &[
|
||||
b"IOSCSIPeripheralDeviceNub\0",
|
||||
b"IOBDBlockStorageDevice\0",
|
||||
b"IODVDBlockStorageDevice\0",
|
||||
b"IOCDBlockStorageDevice\0",
|
||||
b"IOBlockStorageDevice\0",
|
||||
];
|
||||
|
||||
// Walk up to 10 levels (more than enough)
|
||||
for _ in 0..10 {
|
||||
let mut parent: IOObject = 0;
|
||||
let kr = unsafe {
|
||||
IORegistryEntryGetParentEntry(current, b"IOService\0".as_ptr(), &mut parent)
|
||||
};
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if this parent matches any of our target classes
|
||||
for class in target_classes {
|
||||
if unsafe { IOObjectConformsTo(parent, class.as_ptr()) } != 0 {
|
||||
return Some(parent);
|
||||
}
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
None
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
//! SCSI/MMC command interface.
|
||||
//!
|
||||
//! Platform backends are in separate files:
|
||||
//! - `linux.rs` — SG_IO ioctl
|
||||
//! - `macos.rs` — IOKit SCSITaskDeviceInterface
|
||||
//! - `windows.rs` — SPTI (planned)
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
// ── SCSI opcodes (SPC-4, MMC-6) ────────────────────────────────────────────
|
||||
|
||||
pub const SCSI_INQUIRY: u8 = 0x12;
|
||||
pub const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
pub const SCSI_READ_10: u8 = 0x28;
|
||||
pub const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
pub const SCSI_READ_TOC: u8 = 0x43;
|
||||
pub const SCSI_GET_CONFIGURATION: u8 = 0x46;
|
||||
pub const SCSI_SET_CD_SPEED: u8 = 0xBB;
|
||||
pub const SCSI_SEND_KEY: u8 = 0xA3;
|
||||
pub const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
pub const SCSI_READ_12: u8 = 0xA8;
|
||||
pub const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
|
||||
|
||||
/// AACS key class for REPORT KEY / SEND KEY commands.
|
||||
pub const AACS_KEY_CLASS: u8 = 0x02;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DataDirection {
|
||||
None,
|
||||
FromDevice,
|
||||
ToDevice,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScsiResult {
|
||||
pub status: u8,
|
||||
pub bytes_transferred: usize,
|
||||
pub sense: [u8; 32],
|
||||
}
|
||||
|
||||
/// Low-level SCSI transport — one implementation per platform.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
}
|
||||
|
||||
// ── Platform-agnostic open ──────────────────────────────────────────────────
|
||||
|
||||
/// Open a SCSI transport for the given device path.
|
||||
/// Selects the right backend for the current platform.
|
||||
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ Ok(Box::new(linux::SgIoTransport::open(device)?)) }
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{ Ok(Box::new(macos::MacScsiTransport::open(device)?)) }
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{ Err(Error::DeviceNotFound { path: format!("{}: Windows not yet supported", device.display()) }) }
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{ Err(Error::DeviceNotFound { path: format!("{}: unsupported platform", device.display()) }) }
|
||||
}
|
||||
|
||||
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
|
||||
|
||||
/// SCSI INQUIRY response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InquiryResult {
|
||||
pub vendor_id: String,
|
||||
pub model: String,
|
||||
pub firmware: String,
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Send INQUIRY and parse standard response fields.
|
||||
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
|
||||
let cdb = [SCSI_INQUIRY, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||
let mut buf = [0u8; 96];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
|
||||
Ok(InquiryResult {
|
||||
vendor_id: String::from_utf8_lossy(&buf[8..16]).trim().to_string(),
|
||||
model: String::from_utf8_lossy(&buf[16..32]).trim().to_string(),
|
||||
firmware: String::from_utf8_lossy(&buf[32..36]).trim().to_string(),
|
||||
raw: buf.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
|
||||
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let cdb = [SCSI_GET_CONFIGURATION, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut buf = [0u8; 16];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
Ok(buf.to_vec())
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB.
|
||||
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
|
||||
[
|
||||
SCSI_READ_BUFFER, mode, buffer_id,
|
||||
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
|
||||
(length >> 16) as u8, (length >> 8) as u8, length as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a SET CD SPEED CDB.
|
||||
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
[
|
||||
SCSI_SET_CD_SPEED, 0x00,
|
||||
(read_speed >> 8) as u8, read_speed as u8,
|
||||
0xFF, 0xFF,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a READ(10) CDB with the raw read flag.
|
||||
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
|
||||
[
|
||||
SCSI_READ_10, 0x08,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
+1
-3
@@ -65,9 +65,7 @@ impl DriveSpeed {
|
||||
22_001..=31_000 => DriveSpeed::BD6x,
|
||||
31_001..=40_000 => DriveSpeed::BD8x,
|
||||
40_001..=49_000 => DriveSpeed::BD10x,
|
||||
49_001..=0xFFFE => DriveSpeed::BD12x,
|
||||
0xFFFF => DriveSpeed::Max,
|
||||
_ => DriveSpeed::Max,
|
||||
49_001..=u16::MAX => DriveSpeed::BD12x,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+125
-3
@@ -26,11 +26,15 @@ use crate::drive::DriveSession;
|
||||
pub struct UdfFs {
|
||||
/// Root directory with full tree
|
||||
pub root: DirEntry,
|
||||
/// UDF Volume Identifier from Primary Volume Descriptor
|
||||
pub volume_id: String,
|
||||
/// Physical partition start (absolute sector)
|
||||
partition_start: u32,
|
||||
/// Metadata partition start (absolute sector)
|
||||
/// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs
|
||||
metadata_start: u32,
|
||||
/// Metadata partition size in sectors
|
||||
metadata_sectors: u32,
|
||||
}
|
||||
|
||||
/// A directory or file entry.
|
||||
@@ -47,6 +51,12 @@ pub struct DirEntry {
|
||||
}
|
||||
|
||||
impl UdfFs {
|
||||
/// Physical partition start sector.
|
||||
pub fn partition_start(&self) -> u32 { self.partition_start }
|
||||
|
||||
/// Metadata partition start sector.
|
||||
pub fn metadata_start(&self) -> u32 { self.metadata_start }
|
||||
|
||||
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
|
||||
/// Path matching is case-insensitive.
|
||||
pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
|
||||
@@ -87,9 +97,11 @@ impl UdfFs {
|
||||
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
|
||||
// Read file data sector by sector
|
||||
// File DATA is in the physical partition (partition_start + lba),
|
||||
// NOT the metadata partition. ICBs are in metadata, data is in physical.
|
||||
let sector_count = ((data_len as u64 + 2047) / 2048) as u32;
|
||||
let mut data = vec![0u8; (sector_count as usize) * 2048];
|
||||
let abs_start = self.meta_to_abs(data_lba);
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
|
||||
for i in 0..sector_count {
|
||||
let offset = (i as usize) * 2048;
|
||||
@@ -100,6 +112,59 @@ impl UdfFs {
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Collect all sector ranges needed for disc-info and AACS.
|
||||
///
|
||||
/// Returns a list of (start_lba, sector_count) ranges covering:
|
||||
/// - UDF structure (AVDP, VDS, metadata partition, directories)
|
||||
/// - BDMV/PLAYLIST/*.mpls, CLIPINF/*.clpi, JAR/*, META/*, *.bdmv
|
||||
/// - AACS/* (Content*.cer, Unit_Key_RO.inf, CPSUnit*.cci)
|
||||
///
|
||||
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
|
||||
/// MKB_RO.inf, ContentHash*, ContentRevocation*
|
||||
pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure: sector 0 through end of metadata partition
|
||||
// Covers AVDP, VDS, partition descriptor, metadata ICB, FSD, all directories
|
||||
let meta_end = self.metadata_start + self.metadata_sectors;
|
||||
ranges.push((0, meta_end));
|
||||
|
||||
// Walk tree, collect ranges for each metadata file
|
||||
self.collect_file_ranges(session, &self.root, &mut ranges)?;
|
||||
|
||||
// Merge overlapping/adjacent ranges and sort
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
let merged = merge_ranges(&ranges);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
|
||||
for child in &entry.entries {
|
||||
if child.is_dir {
|
||||
// Only skip STREAM — those are the multi-GB video files
|
||||
if child.name.eq_ignore_ascii_case("STREAM") {
|
||||
continue;
|
||||
}
|
||||
self.collect_file_ranges(session, child, ranges)?;
|
||||
} else {
|
||||
// Include the ICB sector itself (in metadata partition)
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
|
||||
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
|
||||
if child.size > 50_000_000 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
let sector_count = (data_len + 2047) / 2048;
|
||||
ranges.push((abs_start, sector_count));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a metadata-partition-relative LBA to an absolute sector number.
|
||||
fn meta_to_abs(&self, meta_lba: u32) -> u32 {
|
||||
self.metadata_start + meta_lba
|
||||
@@ -179,6 +244,8 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
let mut partition_start: u32 = 0;
|
||||
let mut num_partition_maps: u32 = 0;
|
||||
let mut lvd_sector: Option<u32> = None;
|
||||
let mut volume_id = String::new();
|
||||
let mut metadata_size_bytes: u32 = 0;
|
||||
|
||||
for i in 32..64 {
|
||||
let mut desc = [0u8; 2048];
|
||||
@@ -186,6 +253,10 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
|
||||
match desc_tag {
|
||||
// Primary Volume Descriptor — volume identifier at offset 24, 32-byte d-string
|
||||
1 => {
|
||||
volume_id = parse_dstring(&desc[24..56]);
|
||||
}
|
||||
// Partition Descriptor — tells us where the physical partition starts
|
||||
5 => {
|
||||
partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
|
||||
@@ -219,7 +290,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
// Parse partition maps starting at offset 440
|
||||
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
|
||||
let pm1_type = lvd[440]; // First map type
|
||||
let _pm1_type = lvd[440]; // First map type
|
||||
let pm1_len = lvd[441] as usize;
|
||||
|
||||
if pm1_len > 0 && 440 + pm1_len < 2048 {
|
||||
@@ -239,8 +310,9 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
let l_ea = u32::from_le_bytes([meta_icb[208], meta_icb[209],
|
||||
meta_icb[210], meta_icb[211]]) as usize;
|
||||
let ad_off = 216 + l_ea;
|
||||
let _ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
|
||||
let ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
|
||||
meta_icb[ad_off + 2], meta_icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
metadata_size_bytes = ad_len;
|
||||
let ad_pos = u32::from_le_bytes([meta_icb[ad_off + 4], meta_icb[ad_off + 5],
|
||||
meta_icb[ad_off + 6], meta_icb[ad_off + 7]]);
|
||||
// Metadata content starts at partition_start + ad_pos
|
||||
@@ -279,10 +351,14 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
// Step 5: Read root directory and build file tree
|
||||
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
|
||||
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
|
||||
|
||||
Ok(UdfFs {
|
||||
root,
|
||||
volume_id,
|
||||
partition_start,
|
||||
metadata_start,
|
||||
metadata_sectors,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -454,6 +530,52 @@ fn parse_udf_name(data: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge overlapping or adjacent (start, count) ranges.
|
||||
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
|
||||
if ranges.is_empty() { return Vec::new(); }
|
||||
let mut result = vec![ranges[0]];
|
||||
for &(start, count) in &ranges[1..] {
|
||||
let last = result.last_mut().unwrap();
|
||||
let last_end = last.0 + last.1;
|
||||
if start <= last_end + 1 {
|
||||
// Overlapping or adjacent — extend
|
||||
let new_end = (start + count).max(last_end);
|
||||
last.1 = new_end - last.0;
|
||||
} else {
|
||||
result.push((start, count));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse a UDF d-string (fixed-length field with length byte at the end).
|
||||
/// Used for Volume Identifier and other UDF descriptor strings.
|
||||
/// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE.
|
||||
fn parse_dstring(data: &[u8]) -> String {
|
||||
if data.is_empty() { return String::new(); }
|
||||
let len = *data.last().unwrap() as usize;
|
||||
if len == 0 || len > data.len() { return String::new(); }
|
||||
let content = &data[..len];
|
||||
if content.is_empty() { return String::new(); }
|
||||
match content[0] {
|
||||
8 => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
|
||||
16 => {
|
||||
let mut s = String::new();
|
||||
let chars = &content[1..];
|
||||
for i in (0..chars.len()).step_by(2) {
|
||||
if i + 1 < chars.len() {
|
||||
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
|
||||
if c != 0 {
|
||||
if let Some(ch) = char::from_u32(c as u32) { s.push(ch); }
|
||||
}
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
_ => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single 2048-byte sector from the drive.
|
||||
/// Uses standard READ(10) — no unlock required.
|
||||
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user