Two correctness defects in AACS 2.1 / FMTS key-map resolution, both order- or anchor-dependent, and both able to abort a whole disc or silently garble it. **The single-CPS short-circuit depended on which title resolved first.** `pool_len` counted the WHOLE unit-key pool, and resolve_fmts_key_map appends the disc's forensic index keys to that same caller-owned pool. The count was captured before THIS call's FMTS branch but not before earlier titles', so once any forensic title resolved, every later title saw a pool larger than one and fell into multi-CPS sampling — 8 random reads per extent, and a whole-disc DecryptFailed if no pooled key opened a menu extent's samples. A disc that ripped fine when a non-forensic playlist sorted first failed when a forensic one did. Forensic keys are now tagged FMTS_POOL_TAG_BASE = 1 << 24 and the short-circuit asks single_base_key_slot(), which excludes them. The old 1000 tag was NOT kept, and the reasoning is worth recording: base CPS ids are Unit_Key_RO.inf position + 1 and that count is a BE16, so 1000 sits inside a genuinely reachable id space. 1<<24 cannot collide. The id field is cosmetic — decrypt.rs indexes the pool by slot and reads only the key — so widening the tag is safe. **Forensic segment SPNs were anchored to the wrong clip.** They live in the forensic feature clip's byte space, but were mapped through clip_byte_to_lba(&title.extents, ..), which treats byte 0 as the start of the title's FIRST extent. Any playlist not beginning with the forensic clip mapped every segment to the wrong LBA: either the anchor probe sampled the wrong clip and the whole-disc resolve aborted with FmtsKeyMissing, or — worse — a forensic index key was applied to non-forensic sectors while the real forensic units kept the base key, giving silently garbled output with no error at all. The correct anchor turns out to be a DISC fact, not title data: an AACS 2.1 disc names its forensic feature BDMV/STREAM/<clip>.fmts, and carries one IndividualSegment.tbl, so the SPNs are in that one clip's byte space. A new forensic_clip_extents() finds the unique .fmts in the already-walked UDF tree, and those extents now drive the segment arithmetic, the addressability filter, and the index probe — whose title parameter is gone, since its reads were mis-anchored too. "Does this title carry forensic content" is now "does it read the forensic clip's sectors" rather than "do the segment bytes land somewhere in the concatenation". Where the clip is NOT identifiable — no .fmts, or several, making the SPN space ambiguous — on a disc that does carry a non-empty table, the resolve now fails loud with FmtsKeyMissing rather than guessing an anchor. That is a deliberate behaviour change: a hypothetical disc with two .fmts clips hard-fails where it previously produced a possibly-wrong map. Failing loud beats silently garbled output, and inventing an anchor was not acceptable. This site had been flagged independently three times — by the agent that added the FMTS per-disc memo, by the round-4 correctness lens with a concrete scenario, and by the round-4 conformance pass. Verified red here independently: reverting single_base_key_slot to count the whole pool fails both new tests. The agent's own evidence was probe_reads 48 vs 40 (the 8 extra sampling reads) and an E7013 DecryptFailed whole-disc abort, and for the anchor an E7026 FmtsKeyMissing on a [trailer, forensic] extent list. All six pre-existing FMTS tests pass unchanged through the new anchor, including the exact-cost assertions (40 probe reads, one key-service call per disc, one UDF walk for 60 titles), so the round-3 memoisation wins are intact.
libfreemkv
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a keydb.cfg (default ~/.config/freemkv/keydb.cfg) supplying disc-specific volume unique keys; no AACS key material is compiled in.
12+ MB/s sustained read speeds on BD. Drive prep (init()) handles unlocking internally via the freemkv-unlock crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
Part of the freemkv project.
Install
Consumed by git tag (not published to crates.io):
[dependencies]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
Quick Start
use libfreemkv::{Drive, Disc, ScanOptions};
use std::path::Path;
// Open drive — identified via INQUIRY
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
drive.wait_ready()?; // wait for disc
drive.init()?; // unlock + prep (handled internally)
drive.probe_disc()?; // probe disc surface for optimal speeds
// Scan disc — UDF, playlists, streams, AACS (all automatic)
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
for title in &disc.titles {
println!("{} — {} streams", title.duration_display(), title.streams.len());
}
// Stream pipeline — read PES frames from any source, write to any output
let opts = libfreemkv::InputOptions::default();
let mut input = libfreemkv::input("iso://Disc.iso", &opts)?;
let title = input.info().clone();
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
while let Ok(Some(frame)) = input.read() {
output.write(&frame)?;
}
output.finish()?;
Multi-pass recovery rip
Recovery moved OUT of this crate in 1.6.0. The sweep/patch strategy, the
ddrescue mapfile, damage classification and the multipass loop now live in the
freemkv-engine crate as freemkv_engine::recovery::{copy, sweep, patch}.
libfreemkv keeps the layers underneath: the raw single-shot read
(Drive::read) and the SCSI-fact translation (SenseFamily) that the engine's
strategy is built on. The dependency runs engine → libfreemkv, so this crate
cannot call into it; front-ends get recovery from the engine directly. See
docs/rip-recovery.md for what stayed here.
What It Does
- Drive access — open, identify, internal unlock + prep, speed control, eject
- 12+ MB/s reads — auto-detects kernel transfer limits, sustained full speed
- Disc scanning — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
- Stream labels — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- AACS decryption — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
- KEYDB updates — download, verify, save from any HTTP URL (zero deps, raw TCP)
- Content reading — adaptive batch reads with automatic decryption
- Stream I/O — unified stream pipeline for reading and writing any format
Streams
| Stream | Input | Output | Transport |
|---|---|---|---|
| DiscStream | Yes | -- | Optical drive via SCSI |
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written by freemkv_engine::recovery) |
| MkvStream | Yes | Yes | Matroska container |
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
Streams implement a single unified pes::Stream trait (re-exported as PesStream) exposing read() and write() on one type. input() / output() resolve URL strings to PES stream instances. All URLs use the scheme://path format — bare paths are rejected.
Keys
DVDs (CSS) decrypt out of the box, with no external key file needed.
Blu-rays and UHD (AACS) require a keydb.cfg at ~/.config/freemkv/keydb.cfg (or passed via ScanOptions). No AACS key material is compiled into the binary.
Architecture
Drive — open, identify, init, single-shot read
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
└── unlock_bridge — private seam to the freemkv-unlock crate
(firmware / AACS cert / CSS bus-auth unlockers)
Disc — scan titles, streams, AACS/CSS 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
├── IFO parser — DVD title sets, PGC chains, cell addresses
├── Labels — 5 BD-J format parsers (detect + parse)
├── AACS — key resolution + content decryption
├── CSS — DVD CSS (bus auth → player-key disc crack → known-plaintext title-key attack)
└── KEYDB — download + verify + save
Streams — unified PES pipeline
├── PesStream — pes::Stream: one trait, read()/write() PES frames
├── DiscStream — sectors → decrypt → TS demux → PES
├── IsoStream — ISO file → decrypt → TS demux → PES
├── MkvStream — MKV mux/demux
├── M2tsStream — BD transport stream
├── NetworkStream — TCP with FMKV metadata header
├── StdioStream — stdin/stdout pipe
└── NullStream — discard sink
See docs/ for detailed technical documentation on each module.
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 |
| E8xxx | KEYDB update errors |
| E9xxx | Stream / mux errors (URL, PES, ISO, pipeline, demux) |
Platform Support
| Platform | Status | Backend |
|---|---|---|
| Linux | Supported | SG_IO ioctl |
| macOS | Supported | IOKit SCSITask |
| Windows | Supported | SPTI |
Contributing
Run freemkv info disc:// --share with the freemkv CLI to capture your drive's identity for contribution. Drive-unlock profiles are maintained in the freemkv-unlock repository.
License
MIT