rc2: macOS cross-compile fix + security/recovery hardening

- build.rs: pass target -arch to cc so macos_shim cross-compiles (x86_64-apple-darwin)
- AACS/CSS: unit-aligned decrypting sweep; per-VTS CSS title keys (hard-fail on wrong VTS);
  reject truncated Unit_Key_RO; AACS 2.0 sig-verify skip; CSS bus-auth random nonce
- recovery: gap-filling mapfile load; sweep/copy resume reconciliation; stale-mapfile abort;
  patch wedge/damage-window range reset
- mux: TS continuity + PSI CC desync guards; HEVC numTemporalLayers clamp; MPEG-2 pending
  byte-cap; PS parse_pts marker-bit validation; HdrFormat strict parse; Unknown-variant metadata
- net/keydb: network:// SSRF parity (IPv4-mapped, CGNAT, 0.0.0.0/8, Class-E); bounded keydb
  header read + size cap + error context
- io: durable mapfile fsync; NFS writeback degrade; sync_file_range error capture;
  Windows SCSI u32 transfer guard
This commit is contained in:
Matthew Jackson
2026-06-22 08:58:10 -07:00
parent 5941c059c6
commit 337e77951c
25 changed files with 1722 additions and 153 deletions
+92 -1
View File
@@ -179,6 +179,25 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
}
.into());
}
// Split host:port on the LAST ':' so a bracketed IPv6 literal
// (`[2001:db8::1]:9000`) splits at the port colon, not an address colon.
// Require the port substring to be a non-empty u16 — `host:` (empty) and
// `host:abc` (non-numeric) are invalid, despite containing ':'.
let port = match addr.rsplit_once(':') {
Some((_host, port)) => port,
None => {
return Err(crate::error::Error::StreamUrlMissingPort {
addr: addr.to_string(),
}
.into());
}
};
if port.is_empty() || port.parse::<u16>().is_err() {
return Err(crate::error::Error::StreamUrlInvalid {
url: addr.to_string(),
}
.into());
}
Ok(())
}
@@ -213,6 +232,16 @@ fn aacs_key_missing(raw: bool, has_aacs: bool, keys: &crate::decrypt::DecryptKey
!raw && has_aacs && matches!(keys, crate::decrypt::DecryptKeys::None)
}
/// CSS analogue of [`aacs_key_missing`]. Returns `true` when decryption is
/// requested (`!raw`), the disc is CSS-encrypted (`has_css`), and per-title key
/// resolution yielded no usable key (`keys` is
/// [`crate::decrypt::DecryptKeys::None`] — e.g. a multi-VTS DVD whose chosen
/// title's VTS could not be re-cracked). Muxing that would emit scrambled
/// ciphertext, so the caller fails fast with [`Error::CssKeyMissing`].
fn css_key_missing(raw: bool, has_css: bool, keys: &crate::decrypt::DecryptKeys) -> bool {
!raw && has_css && matches!(keys, crate::decrypt::DecryptKeys::None)
}
/// Open a PES input stream (produces PES frames).
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
@@ -276,13 +305,29 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
.into());
}
// Per-title key resolution. For a multi-VTS CSS DVD the scan's
// single cracked key only descrambles its own VTS; re-crack from
// the chosen title's extents if it lives elsewhere. A fresh reader
// avoids disturbing the mux reader below. 64 sectors is a
// file-safe batch for an ISO. AACS / single-VTS paths are
// unchanged (decrypt_keys_for_title short-circuits to decrypt_keys).
let keys = match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(mut crack_reader) => disc.decrypt_keys_for_title(idx, &mut crack_reader, 64),
Err(_) => disc.decrypt_keys(),
};
// CSS no-key guard (parallel to the AACS gate above): on a CSS
// disc, decrypt_keys_for_title may return `None` when the chosen
// title's VTS could not be re-cracked. Muxing that would emit
// scrambled ciphertext verbatim, so fail loudly here instead.
if css_key_missing(opts.raw, disc.css.is_some(), &keys) {
return Err(crate::error::Error::CssKeyMissing.into());
}
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
// by probing the first DECRYPTED access units of the chosen title.
// A fresh reader avoids disturbing the mux reader below. Skipped in
// --raw mode: the probe would re-open + decrypt for nothing (on an
// AACS disc with no key the correction is a no-op on ciphertext, and
// raw output isn't decoded anyway).
let keys = disc.decrypt_keys();
if !opts.raw {
match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(probe) => {
@@ -593,6 +638,7 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
#[cfg(test)]
mod tests {
use super::aacs_key_missing;
use super::css_key_missing;
use super::validate_network_addr;
use super::{build_demux_state, build_iso_pipeline, input, output};
use crate::decrypt::DecryptKeys;
@@ -612,6 +658,26 @@ mod tests {
assert!(validate_network_addr("host:9000").is_ok());
}
#[test]
fn validate_network_addr_requires_numeric_port() {
// An empty port (`host:`) and a non-numeric port (`host:abc`) both
// contain ':' but are NOT valid host:port — must be rejected.
assert!(validate_network_addr("host:").is_err());
assert!(validate_network_addr("127.0.0.1:").is_err());
assert!(validate_network_addr("host:abc").is_err());
assert!(validate_network_addr("host:99x").is_err());
// Out-of-u16-range port is rejected (parse::<u16> fails).
assert!(validate_network_addr("host:70000").is_err());
// Bracketed IPv6 with a valid port passes; split on the LAST ':' so the
// address colons are not mistaken for the port separator.
assert!(validate_network_addr("[2001:db8::1]:9000").is_ok());
// Bracketed IPv6 WITHOUT a port is rejected (port substring not a u16).
assert!(validate_network_addr("[2001:db8::1]").is_err());
// Valid numeric port (incl. 0 and max u16) passes.
assert!(validate_network_addr("host:0").is_ok());
assert!(validate_network_addr("host:65535").is_ok());
}
fn aacs_keys() -> DecryptKeys {
DecryptKeys::Aacs {
unit_keys: vec![(1, [0x11u8; 16])],
@@ -644,6 +710,31 @@ mod tests {
assert!(!aacs_key_missing(false, false, &css_keys()));
}
#[test]
fn css_no_key_aborts() {
// CSS disc, decryption requested, per-title resolver yielded None
// (e.g. an un-re-crackable VTS) → abort instead of muxing ciphertext.
assert!(css_key_missing(false, true, &DecryptKeys::None));
}
#[test]
fn css_with_key_proceeds() {
// CSS disc with a resolved title key → proceed.
assert!(!css_key_missing(false, true, &css_keys()));
}
#[test]
fn css_raw_never_aborts() {
// --raw skips decryption: never abort even with no CSS key.
assert!(!css_key_missing(true, true, &DecryptKeys::None));
}
#[test]
fn css_guard_ignores_non_css() {
// No CSS state (AACS / unencrypted): the CSS guard never fires.
assert!(!css_key_missing(false, false, &DecryptKeys::None));
}
#[test]
fn raw_never_aborts() {
// --raw skips decryption — must never hit the no-key abort, even on an