The same hole, two disc families, two answers — and the asymmetry is now a
decision written into both files instead of an oversight in one.
`file_extents` can return `Ok` and still yield no usable extent: an empty
allocation-descriptor list, or one every entry of which the `sectors > 0 &&
lba > 0` filter discards. An ordinary zero-byte file reaches it; no crafted
disc is needed.
On HD-DVD that was the flagship failure shape. The clip entered neither
`clip_extents` nor `unusable`, and nothing was logged, so the composer's
`any(|n| unusable.contains(..))` guard missed it while the
`filter(|n| clip_extents.contains_key(..))` beside it quietly deleted the
part: a `FEATURE_2.EVO` of size 0 next to a healthy `FEATURE_1.EVO` composed
a FEATURE title out of part one alone, still advertising the whole runtime,
at rc=0, in silence. Half a movie presented as a whole one. Round 1 accounted
for every `Err` from the resolver and left this route open. It now marks the
clip unusable and logs it under its own new code, E6019
(`E_UDF_NO_USABLE_EXTENT`) — deliberately not the neighbouring E6017, which
would file a zero-length file as an authoring hole and send whoever triages
it at the wrong population.
On Blu-ray the identical hole stays open, as previously decided, and the
reasons are now recorded on both sides. BD has no `unusable` set, so closing
it there means inventing a post-loop "every clip_id must appear in `spans`"
invariant that DROPS the title, and it is not settled that an empty-but-Ok
resolve is always a defect; dropping healthy titles is worse than the gap.
The consequence is milder too: on BD the clip is one PlayItem of an otherwise
whole title, on HD-DVD the feature is COMPOSED from parts. Same hole,
different price.
Also in this change:
* bluray: a non-absence SSIF failure that the `.m2ts` fallback papers over is
logged. `unresolved` had exactly one reader, `if let (None, Some(code))`, so
when `/BDMV/STREAM/SSIF/<clip>.ssif` failed with DiscRead /
UdfAdChainTooLong / UdfEmbeddedData and the base view then resolved, the
code was recorded and thrown away: the title shipped base-view 2D off a 3D
disc at rc=0 with no log at all. The site's own doctrine is "ABSENCE is the
only benign failure". Logged, not refused — the base view is a real rip.
* drive: `wait_ready` polled TEST UNIT READY through a bare `execute` and its
60 x 500 ms loop never read `self.halt`, so a Stop during spin-up did
nothing for ~30 s while every other drive path returns Halted at the next
command boundary. `spin_cycle` issued both START STOP UNIT commands outside
`checked_exec` and slept `SPIN_DOWN_IDLE_SECS` + `SPIN_UP_SETTLE_SECS`
blind — ~15 s deaf to Stop, from the recovery path, exactly when the
operator is most likely to press it. Both now use `checked_exec` and
`sleep_until_halted`, which already lived in this file with four tests and
was `#[cfg(test)]`, called from nowhere. It is production code again.
* drive: a READ(10) that returns GOOD status with a residual underrun was
correctly refused and logged NOWHERE, while the sibling `Err` arm warns with
lba/count/status. A residual-underrunning drive was indistinguishable from a
scratched disc — two populations with opposite remedies. It now warns with
transferred vs expected, which is the whole signal.
* error: `all_error_code_constants_are_unique` was a hand-maintained `vec![]`
naming 109 of the 127 declared codes while its doc claimed to pin them all,
and an earlier audit trusted that claim while assigning new ones. The list
is now derived from the declarations by parsing `include_str!("error.rs")`,
so a new constant is covered the moment it is written. A parser self-test
cross-checks the count and three known name/value pairs, so it cannot pass
vacuously.
* testlog: a test-only `tracing` capture (~120 lines, no new dependency) so
the logging contract is enforced rather than commented. Three sites carry
long comments insisting they log the error's OWN code; putting a literal
back broke nothing. They are pinned now, along with the two new log lines.
Captures are serialised process-wide: `tracing`'s interest cache is global
while `with_default` is thread-local, and the rebuild on the exiting
capture can land after the entering one's, leaving the cache at "never"
while a capture is live. That produced a real empty-event flake.
* disc: `scan_with`'s halt wiring for the BD and DVD enumerators had no test —
every BD/DVD cancellation test calls the scanners directly, so passing
`None` on either branch left the suite green while Stop did nothing.
* mux::network: `accept_from_rejects_stream_without_fmkv_header` half-closes
instead of `Shutdown::Both`, which raced an RST against the server's read
and returned ConnectionReset instead of InvalidInput under load. The port
was already ephemeral; that was never the cause.
Gate: fmt, clippy --all-targets -D warnings, and 3439 tests green on 1.97;
precommit.sh libfreemkv clean.
293 lines
14 KiB
Rust
293 lines
14 KiB
Rust
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
|
//!
|
|
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
|
//! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS
|
|
//! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock`
|
|
//! crate; libfreemkv consumes it privately and exposes none of it, so clients
|
|
//! are oblivious to unlockers (just as they are to the SCSI layer).
|
|
//!
|
|
//! # Quick Start
|
|
//!
|
|
//! ```no_run
|
|
//! use libfreemkv::{Drive, Disc, ScanOptions, find_drive};
|
|
//!
|
|
//! let mut drive = find_drive().expect("no optical drive found");
|
|
//! drive.wait_ready().unwrap();
|
|
//! drive.init().unwrap();
|
|
//! let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
|
//!
|
|
//! for title in &disc.titles {
|
|
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
|
|
//! }
|
|
//! ```
|
|
//!
|
|
//! Muxing to an output container runs through the PES pipeline. A live
|
|
//! `disc://` cannot be opened via [`input`] — it returns
|
|
//! [`Error::DiscUrlNotDirect`] by design (use `Drive` + `Disc::scan` +
|
|
//! `DiscStream::new` directly for a live drive). Any file-backed source
|
|
//! (`iso://`, `m2ts://`) opens through [`input`]:
|
|
//!
|
|
//! ```no_run
|
|
//! # fn run() -> std::io::Result<()> {
|
|
//! 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, None)?;
|
|
//! // Propagate read errors instead of silently stopping on the first one.
|
|
//! while let Some(frame) = input.read()? {
|
|
//! output.write(&frame)?;
|
|
//! }
|
|
//! output.finish()?;
|
|
//! # Ok(())
|
|
//! # }
|
|
//! ```
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! ```text
|
|
//! Drive -- open, identify, unlock, read sectors
|
|
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
|
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
|
//! └── unlock_bridge -- private seam to the `freemkv-unlock` crate
|
|
//! (firmware / AACS cert / CSS bus-auth unlockers)
|
|
//!
|
|
//! 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
|
|
//! └── host_certs -- collect host certs (cert handshake lives in freemkv-unlock)
|
|
//! ```
|
|
//!
|
|
//! # 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 |
|
|
//! | E8xxx | Keydb errors (fetch, parse, load) |
|
|
//! | E9xxx | Stream / mux errors (URL, PES, pipeline) |
|
|
|
|
/// Single source of truth for every freemkv version surface.
|
|
///
|
|
/// `FREEMKV_VERSION` is the package version, overridable at build time via the
|
|
/// `FREEMKV_BUILD_LABEL` env (see `build.rs`); `GIT_SUFFIX` is the git short
|
|
/// hash. The CLI's `--version`, the MKV muxing/writing-application field, and
|
|
/// the FVI generator tag all derive from these two consts, so a binary reports
|
|
/// the exact same label it stamps into the files it produces — no split-brain
|
|
/// where an MKV claims one version and the binary another.
|
|
pub const VERSION_LABEL: &str = concat!(env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
|
|
|
|
/// The muxing/writing-application string written into MKV output
|
|
/// (`"freemkv <version> (g<hash>)"`).
|
|
pub(crate) const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
|
|
|
|
pub mod aacs;
|
|
pub(crate) mod clpi;
|
|
pub mod consts;
|
|
pub mod css;
|
|
pub mod decrypt;
|
|
pub mod diag;
|
|
pub mod dirimage;
|
|
pub mod disc;
|
|
pub mod drive;
|
|
pub mod dvdnav;
|
|
pub mod error;
|
|
pub mod event;
|
|
pub mod halt;
|
|
#[cfg(test)]
|
|
mod harness;
|
|
pub mod hex;
|
|
pub(crate) mod identity;
|
|
pub(crate) mod ifo;
|
|
pub mod io;
|
|
pub mod keysource;
|
|
pub mod labels;
|
|
pub(crate) mod mpls;
|
|
pub mod mux;
|
|
pub mod pes;
|
|
pub(crate) mod platform;
|
|
pub mod progress;
|
|
pub mod scsi;
|
|
pub mod sector;
|
|
pub mod session;
|
|
#[cfg(test)]
|
|
pub(crate) mod testlog;
|
|
pub(crate) mod udf;
|
|
pub(crate) mod unlock_bridge;
|
|
|
|
// ─── Drive lifecycle ────────────────────────────────────────────────────────
|
|
//
|
|
// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive`
|
|
// owns the SCSI session; `DriveCapture` etc. let advanced callers introspect
|
|
// drive identity / profile data for sharing.
|
|
pub use drive::capture::{
|
|
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
|
};
|
|
pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive};
|
|
|
|
// ─── Disc session (drive open + SCSI bring-up hoist) ─────────────────────────
|
|
//
|
|
// One entry point that opens a drive and brings the transport up, so consumers
|
|
// stop hand-rolling `open → wait_ready → init → probe_disc → identify → scan`.
|
|
// Owns the `Drive` by value; forwards consumer-built key material into
|
|
// `ScanOptions` (the library derives no certs — see `KeySpec`).
|
|
pub use session::{
|
|
DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_dir,
|
|
scan_iso,
|
|
};
|
|
|
|
// ─── Errors ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// All fallible APIs return `Result<T, Error>`. `Error` is a typed enum with a
|
|
// numeric `code()`; **no English text in the library** — applications map
|
|
// codes to localized messages. See `error.rs` for the full taxonomy.
|
|
pub use error::{
|
|
Error, Result, error_code, is_disc_level_no_key, is_halt, is_skippable_title_stub,
|
|
};
|
|
|
|
// ─── Cooperative cancellation ───────────────────────────────────────────────
|
|
//
|
|
// One-bit cooperative cancellation token, shared by every long-running loop —
|
|
// libfreemkv's mux, and the recovery passes (sweep/patch) that now live in the
|
|
// freemkv-engine crate. Clone it cheaply; pass it by value into each component;
|
|
// poll `is_cancelled()` inside the loop body.
|
|
pub use halt::Halt;
|
|
|
|
// Generic bounded producer/consumer primitive used by the mux pipeline (and,
|
|
// via this re-export, by the engine's sweep/patch recovery passes) to overlap
|
|
// reads with writes via a dedicated consumer thread.
|
|
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
|
|
// pushes one item with back-pressure; `pipe.finish()` joins the
|
|
// consumer and surfaces its `close()` output. Callers implement `Sink`
|
|
// to define per-item behaviour and end-of-stream finalisation.
|
|
//
|
|
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
|
|
// most should use WRITE_PIPELINE_DEPTH instead.
|
|
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
|
|
// `apply` ends the consumer cleanly (still calls `close()`).
|
|
pub use io::pipeline::{
|
|
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
|
};
|
|
|
|
// ─── Bounded-cache buffered file writer ─────────────────────────────────────
|
|
//
|
|
// Drop-in `std::fs::File` replacement used everywhere the lib writes large
|
|
// sequential output (mux, extract, sweep, patch) — drains dirty pages
|
|
// continuously instead of bursting. General I/O infra, not recovery policy;
|
|
// promoted to `pub` so freemkv-engine's relocated sweep/patch can use it too.
|
|
pub use io::WritebackFile;
|
|
/// Write an image-level source out as a sector image — what an `iso://`
|
|
/// DESTINATION means for any source that is not a physical drive. Drive sources
|
|
/// go through `freemkv_engine::copy`, which is the recovery path; see
|
|
/// [`io::image_writer`] for why the two are deliberately separate.
|
|
pub use io::image_writer::write_image;
|
|
|
|
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
|
pub use event::{BatchSizeReason, Event, EventKind};
|
|
pub use identity::DriveId;
|
|
|
|
// ─── Unlock seam ────────────────────────────────────────────────────────────
|
|
//
|
|
// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS
|
|
// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes
|
|
// it through the private `unlock_bridge` and exposes nothing of it: clients are
|
|
// oblivious to unlockers, exactly as they are to the SCSI layer. There is no
|
|
// public unlock surface to import.
|
|
|
|
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
|
//
|
|
// `Disc::scan()` resolves keys and stores them on `Disc`; in most flows you
|
|
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
|
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
|
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
|
pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
|
|
|
// ─── Disc structure ─────────────────────────────────────────────────────────
|
|
//
|
|
// `Disc::scan()` produces a fully-populated `Disc` (titles, streams, AACS
|
|
// state). `Disc::identify()` is the fast path — UDF only, no playlist parse,
|
|
// for displaying disc name + format quickly while a full scan runs in the
|
|
// background. The codec / channel / resolution enums are the canonical
|
|
// structured representation; never compare against display strings.
|
|
// Note: `disc::Stream` here is the codec enum (audio / video / sub kind)
|
|
// — not the `pes::Stream` trait re-exported below as `PesStream`. Two
|
|
// different concepts, the same short name; the trait gets the `Pes`
|
|
// prefix at the crate root to keep both addressable.
|
|
pub use dirimage::DirImage;
|
|
pub use disc::{
|
|
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
|
DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
|
|
FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, Resolution,
|
|
SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream,
|
|
};
|
|
pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply};
|
|
|
|
// ─── Streams ────────────────────────────────────────────────────────────────
|
|
//
|
|
// All stream types implement `pes::Stream` — read PES frames from a source,
|
|
// write PES frames to a sink. Pick the right type at construction:
|
|
//
|
|
// - `DiscStream` — physical drive or ISO (any `SectorSource`). Read-only.
|
|
// - `MkvStream` — Matroska container. Read on `open()`, write on `create()`.
|
|
// - `M2tsStream` — Blu-ray Transport Stream. Write-only sink (`create()`).
|
|
// - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`.
|
|
// - `NullStream` — write-only black-hole sink. Useful for benchmarks.
|
|
// - `StdioStream` — pipe to/from stdin/stdout. Read or write.
|
|
//
|
|
// Most consumers use the URL resolvers (`input()` / `output()`) which pick
|
|
// the right type from a scheme:// URL. Direct construction is for callers
|
|
// that need to wire custom readers (e.g. autorip's drive-session reuse).
|
|
// The trait is re-exported as `PesStream` here to disambiguate from
|
|
// `disc::Stream` (the codec-kind enum re-exported above), which would
|
|
// otherwise collide at the crate root.
|
|
pub use pes::PesFrame;
|
|
pub use pes::Stream as PesStream;
|
|
|
|
pub use mux::DiscStream;
|
|
pub use mux::M2tsStream;
|
|
pub use mux::MkvStream;
|
|
pub use mux::NetworkStream;
|
|
pub use mux::NullStream;
|
|
pub use mux::StdioStream;
|
|
pub use mux::WriteSeek;
|
|
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
|
pub use mux::{Medium, SourceInfo};
|
|
pub use mux::{Mp4FitReport, Mp4Sink, Mp4SkipReason, mp4_fit_report};
|
|
|
|
// ─── Lower-level surfaces ───────────────────────────────────────────────────
|
|
//
|
|
// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for
|
|
// out-of-tree platform backends. `SectorSource` / `SectorSink` are the
|
|
// direction-typed read/write traits; `FileSectorSource` and `FileSectorSink`
|
|
// are the ISO-on-disk implementations. [`DecryptingSectorSource`] is the
|
|
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
|
// `SectorSource` to get plaintext sectors out.
|
|
pub use mux::build_iso_pipeline;
|
|
pub use mux::resolve_mux_key_map;
|
|
pub use mux::select::{PidFilter, StreamSelection};
|
|
pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
|
|
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives};
|
|
pub use sector::{
|
|
DecryptingSectorSource, FileSectorSource, KeyFetch, PrefetchedSectorSource, SectorSource,
|
|
};
|
|
pub use udf::{UdfFs, read_filesystem};
|