mux: resolve+install AACS key map on live single-pass (Session/Live)
Under the map-only decrypt model an AACS DecryptingSectorSource decrypts
nothing until a key map is installed; with no map the AACS arm fails loud
with DecryptFailed on the first content unit. The two inline live-mux arms
in mux_stream did not install one:
- MuxInput::Session (freemkv `rip disc://…mkv`) installed NO map at all.
- MuxInput::Live (autorip non-FMTS single-pass) installed only a
caller-supplied forensic FMTS map, which is None for a plain AACS disc.
So EVERY plain AACS Blu-ray/UHD ripped via the live single-pass path failed
DecryptFailed on the first content read. This predates the mux_stream
refactor: the bug was introduced with the map-only decrypt model, and the
pre-refactor CLI likewise built DiscStream::new without with_key_map.
Fix: add resolve_inline_base_map, the inline counterpart to what
build_iso_pipeline does for the file highway. Both arms now resolve the
AACS map off the reader (borrow to sample, then move into DiscStream) and
install it via with_key_map before any read. DVD/CSS keeps DecryptKeys::None
(DiscStream's per-title CSS crack owns it); clear/raw resolve to no map.
Session passes session.key_fetch() so a multi-CPS/orphan unit can still be
recovered; a caller-supplied FMTS map (autorip) is used verbatim, never
re-resolved.
Tests: an end-to-end MuxInput::Live mux over a genuinely-AACS-encrypted
synthetic unit now decrypts and finalises (mutation-verified: dropping the
resolve/install makes the mux abort). Adds a pub(crate) test-only AACS
encrypt helper so the mux test can build a real encrypted fixture, and a
gating test for resolve_inline_base_map (AACS→map, CSS/clear/raw→none).
This commit is contained in:
+38
-23
@@ -326,6 +326,41 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only inverse of [`decrypt_unit`]: encrypt a clear aligned unit under
|
||||
/// `unit_key` and set the CPI-encrypted flag (top 2 bits of byte 0) so the unit
|
||||
/// reads as encrypted under [`aacs_unit_encrypted`]. Exposed `pub(crate)` for
|
||||
/// cross-module mux tests (the mux `driver.rs` builds a genuinely-AACS-encrypted
|
||||
/// fixture to prove the live/session decrypt path installs its key map). Uses
|
||||
/// only the module-scope primitives so it stays in lock-step with `decrypt_unit`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return;
|
||||
}
|
||||
// Set CPI bits BEFORE key derivation so the recovered plaintext header matches.
|
||||
unit[0] |= 0xC0;
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
// CBC-encrypt bytes 16.. under the fixed AACS IV (forward of `aes_cbc_decrypt`).
|
||||
let mut prev = AACS_IV;
|
||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
let off = 16 + i * 16;
|
||||
let mut block = [0u8; 16];
|
||||
for j in 0..16 {
|
||||
block[j] = unit[off + j] ^ prev[j];
|
||||
}
|
||||
let enc = aes_ecb_encrypt(&k, &block);
|
||||
unit[off..off + 16].copy_from_slice(&enc);
|
||||
prev.copy_from_slice(&enc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
||||
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
|
||||
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
@@ -615,29 +650,9 @@ mod tests {
|
||||
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
|
||||
/// fixed AACS IV.
|
||||
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
// Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under
|
||||
// `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
|
||||
// header the real decrypt recovers matches what we encrypt under.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = AACS_IV;
|
||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
cipher.encrypt_block(&mut block);
|
||||
unit[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
// Delegate to the module-scope `pub(crate)` helper (the single encrypt
|
||||
// implementation, shared with the mux `driver.rs` decrypt test).
|
||||
super::aacs_encrypt_unit_for_test(unit, unit_key);
|
||||
}
|
||||
|
||||
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
|
||||
|
||||
+283
-12
@@ -38,7 +38,9 @@ use crate::pes::{CountingStream, PesFrame, Stream};
|
||||
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
|
||||
use crate::session::DiscSession;
|
||||
|
||||
use super::resolve::{InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url};
|
||||
use super::resolve::{
|
||||
InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url, resolve_mux_key_map,
|
||||
};
|
||||
|
||||
/// Effectively-unbounded per-frame send deadline used when a consumer passes
|
||||
/// `MuxOptions.send_deadline == None` (the CLI's interactive stdout / network
|
||||
@@ -302,7 +304,7 @@ pub fn mux_stream(
|
||||
// Pull everything we need out of the disc as owned values so the
|
||||
// immutable disc borrow is released before the mutable
|
||||
// `take_reader` below.
|
||||
let (title, format, keys, playlist) = {
|
||||
let (title, format, mut keys, playlist) = {
|
||||
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
|
||||
path: session.device_path().to_string(),
|
||||
})?;
|
||||
@@ -324,9 +326,25 @@ pub fn mux_stream(
|
||||
};
|
||||
// A missing staged reader ("already consumed" / never staged) is a
|
||||
// clean error, not a panic (contract Q2).
|
||||
let reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
|
||||
let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
|
||||
path: session.device_path().to_string(),
|
||||
})?;
|
||||
// Resolve the AACS key map off the STAGED reader BEFORE it is moved
|
||||
// into `DiscStream::new` (borrow to sample, then move to construct).
|
||||
// Without this the AACS `DecryptingSectorSource` inside the stream has
|
||||
// no map and fails `DecryptFailed` on the first content unit — the
|
||||
// single-pass live-mux decrypt bug. `session.key_fetch()` (retained by
|
||||
// `resolve_keys`) recovers a multi-CPS/orphan/forensic unit the pool is
|
||||
// missing. DVD/clear/`raw` resolve to `None` (CSS self-cracks in
|
||||
// `DiscStream::new`; raw is ciphertext passthrough) — unchanged.
|
||||
let base_map = resolve_inline_base_map(
|
||||
&mut *reader,
|
||||
&title,
|
||||
&mut keys,
|
||||
session.key_fetch(),
|
||||
format,
|
||||
opts.raw,
|
||||
)?;
|
||||
let mut stream = crate::mux::DiscStream::new(
|
||||
reader,
|
||||
title,
|
||||
@@ -339,6 +357,9 @@ pub fn mux_stream(
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
if let Some(map) = base_map {
|
||||
stream = stream.with_key_map(map);
|
||||
}
|
||||
stream.skip_errors = opts.skip_errors;
|
||||
// Live path: the `DiscStream` emits the full reader-side vocabulary
|
||||
// (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the
|
||||
@@ -347,12 +368,35 @@ pub fn mux_stream(
|
||||
(Box::new(stream), Some(playlist))
|
||||
}
|
||||
MuxInput::Live {
|
||||
reader,
|
||||
mut reader,
|
||||
title,
|
||||
format,
|
||||
keys,
|
||||
mut keys,
|
||||
key_map,
|
||||
} => {
|
||||
// The map installed BEFORE reads begin. Two sources:
|
||||
// - A caller-supplied `key_map` (autorip's FMTS gate resolved the
|
||||
// forensic per-segment map and passes it here) is used VERBATIM —
|
||||
// never re-resolved.
|
||||
// - `None` on an AACS disc means a plain (non-FMTS) single/multi-CPS
|
||||
// disc that the caller did NOT map. Resolve the base map here off the
|
||||
// live reader, exactly as the `Session` arm and `build_iso_pipeline`
|
||||
// do — otherwise the AACS `DecryptingSectorSource` has no map and
|
||||
// fails `DecryptFailed` on the first content unit (the single-pass
|
||||
// live-mux decrypt bug). Borrow to sample, then move into the stream.
|
||||
// DVD/clear/`raw` → `None` (unchanged: CSS self-cracks in
|
||||
// `DiscStream::new`; raw is ciphertext passthrough).
|
||||
let base_map = match key_map {
|
||||
Some(map) => Some(map),
|
||||
None => resolve_inline_base_map(
|
||||
&mut *reader,
|
||||
&title,
|
||||
&mut keys,
|
||||
None,
|
||||
format,
|
||||
opts.raw,
|
||||
)?,
|
||||
};
|
||||
// INLINE `DiscStream` — the same constructor the `Session` arm uses,
|
||||
// NOT `build_iso_pipeline` (the prefetch highway). The consumer's
|
||||
// adaptive batch-retry lives in `DiscStream::fill_extents`, which the
|
||||
@@ -369,13 +413,14 @@ pub fn mux_stream(
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
// Apply the forensic FMTS key map BEFORE reads begin — rewrites the
|
||||
// extent walk to our-phase units only and installs the map so each
|
||||
// unit decrypts with its mapped key. `None` leaves the walk unchanged
|
||||
// (identical to a plain single/multi-CPS disc). Single-pass FMTS
|
||||
// correctness depends on this: dropping it reads the alternate
|
||||
// device-group units and mis-decrypts the forensic segment.
|
||||
if let Some(map) = key_map {
|
||||
// Apply the key map BEFORE reads begin — for an FMTS forensic map this
|
||||
// rewrites the extent walk to our-phase units only and installs the map
|
||||
// so each unit decrypts with its mapped key; for a plain single/multi-CPS
|
||||
// base map it installs the per-unit content key. `None` leaves the walk
|
||||
// unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on
|
||||
// this: dropping the forensic map reads the alternate device-group units
|
||||
// and mis-decrypts the forensic segment.
|
||||
if let Some(map) = base_map {
|
||||
stream = stream.with_key_map(map);
|
||||
}
|
||||
stream.skip_errors = opts.skip_errors;
|
||||
@@ -433,6 +478,50 @@ fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the base AACS key map for an INLINE live-drive mux (the `Session` /
|
||||
/// `Live` arms) BEFORE the reader is moved into [`DiscStream::new`] — the
|
||||
/// counterpart to the map resolution [`build_iso_pipeline`] performs internally
|
||||
/// for the file highway.
|
||||
///
|
||||
/// Under the map-only decrypt model an AACS [`DecryptingSectorSource`] decrypts
|
||||
/// NOTHING until a key map is installed: with no map the AACS arm of the decrypt
|
||||
/// path fails loud with [`Error::DecryptFailed`] on the first content unit (the
|
||||
/// deliberate "a reader built without its map is a bug" guard). Both inline arms
|
||||
/// used to skip this — `Session` installed no map at all, and `Live` installed
|
||||
/// only a caller-supplied forensic FMTS map (`None` for a plain AACS disc) — so
|
||||
/// EVERY plain AACS Blu-ray/UHD muxed via the live single-pass path failed
|
||||
/// `DecryptFailed` on the first content read. This resolves + returns the map so
|
||||
/// the caller can install it via [`DiscStream::with_key_map`].
|
||||
///
|
||||
/// - AACS keys → resolve (`resolve_mux_key_map`: single-CPS content map,
|
||||
/// multi-CPS per-extent key selection, or FMTS per-segment map) and return
|
||||
/// `Some(map)`. Resolution failure propagates (fail loud), matching the ISO
|
||||
/// path's decrypt gate.
|
||||
/// - CSS / clear / `None` → `Ok(None)`: CSS self-cracks per title inside
|
||||
/// [`DiscStream::new`], and a genuinely-clear disc needs no map.
|
||||
/// - `raw` → `Ok(None)`: ciphertext passthrough, no decrypt step to key.
|
||||
///
|
||||
/// The `reader` is borrowed only to SAMPLE ciphertext here (the UDF/FMTS probe
|
||||
/// and any multi-CPS unit samples); a single-CPS disc — the overwhelming
|
||||
/// majority, including every single-key UHD — resolves its map with NO content
|
||||
/// read beyond the one-time UDF filesystem probe. The caller then moves the same
|
||||
/// reader into [`DiscStream::new`]; reads are by absolute LBA, so the sampling
|
||||
/// leaves no read-position state behind.
|
||||
fn resolve_inline_base_map(
|
||||
reader: &mut dyn SectorSource,
|
||||
title: &DiscTitle,
|
||||
keys: &mut DecryptKeys,
|
||||
fetch: Option<&KeyFetch>,
|
||||
format: crate::disc::ContentFormat,
|
||||
raw: bool,
|
||||
) -> std::io::Result<Option<Arc<AacsKeyMap>>> {
|
||||
if raw || !matches!(keys, DecryptKeys::Aacs { .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
let map = resolve_mux_key_map(reader, title, keys, fetch, format)?;
|
||||
Ok(Some(Arc::new(map)))
|
||||
}
|
||||
|
||||
fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::EventFn {
|
||||
Box::new(move |e: Event| match e.kind {
|
||||
EventKind::BytesRead { bytes, total } => events.on_read_progress(bytes, total),
|
||||
@@ -1346,6 +1435,188 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `SectorSource` that serves ONE genuinely-AACS-encrypted aligned unit
|
||||
/// (6144 bytes) at LBA 0..3 and zeros everywhere else — enough for the
|
||||
/// map-only decrypt path to prove itself end-to-end. Zeros elsewhere make the
|
||||
/// UDF filesystem probe inside `resolve_mux_key_map` fail cleanly (→ not an
|
||||
/// FMTS disc, single-CPS base map).
|
||||
struct AacsUnitReader {
|
||||
unit: Vec<u8>, // 6144 bytes, encrypted
|
||||
capacity: u32,
|
||||
}
|
||||
impl crate::sector::SectorSource for AacsUnitReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(0);
|
||||
// The content unit lives at LBA 0..3; serve it whenever a read starts
|
||||
// there (the inline `DiscStream` reads the [0,3) extent as one batch).
|
||||
if lba == 0 && bytes >= self.unit.len() {
|
||||
buf[..self.unit.len()].copy_from_slice(&self.unit);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one AACS-encrypted BD-TS aligned unit whose plaintext is a single
|
||||
/// audio PES (the same clip the ISO test muxes), encrypted under `unit_key`.
|
||||
fn encrypted_audio_unit(unit_key: &[u8; 16]) -> Vec<u8> {
|
||||
let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22];
|
||||
let pkt = bdts_data_packet(0x1100, true, &audio_pes(&es));
|
||||
let mut unit = vec![0u8; 3 * 2048]; // one 6144-byte aligned unit
|
||||
unit[..192].copy_from_slice(&pkt);
|
||||
crate::aacs::content::aacs_encrypt_unit_for_test(&mut unit, unit_key);
|
||||
unit
|
||||
}
|
||||
|
||||
/// END-TO-END decrypt on the live single-pass `MuxInput::Live` path with a
|
||||
/// plain (non-FMTS) AACS disc and NO caller-supplied key map — the exact shape
|
||||
/// of `freemkv rip disc://…` and autorip's non-FMTS single-pass. The driver's
|
||||
/// `Live` arm must RESOLVE + INSTALL the base AACS key map itself; the unit
|
||||
/// then decrypts to a valid audio PES and the mux drains and finalises.
|
||||
///
|
||||
/// This is the regression guard for the confirmed bug: without the map the
|
||||
/// AACS `DecryptingSectorSource` fails `DecryptFailed` on the first content
|
||||
/// unit and no AACS disc could ever be live-muxed.
|
||||
///
|
||||
/// Mutation: deleting the `resolve_inline_base_map` call (or the
|
||||
/// `stream = stream.with_key_map(map)` install) in the `Live` arm leaves the
|
||||
/// reader mapless → the first content batch cannot decrypt (root cause
|
||||
/// `DecryptFailed`, surfaced through `fill_extents`' non-skip read-error path
|
||||
/// as a `DiscRead`) → `mux_stream` returns `Err` and `out.completed` is never
|
||||
/// reached (verified: the mux aborts instead of finalising).
|
||||
#[test]
|
||||
fn mux_input_live_aacs_without_caller_map_resolves_and_decrypts() {
|
||||
use crate::disc::Extent;
|
||||
|
||||
let unit_key = [0x5Au8; 16];
|
||||
let reader = Box::new(AacsUnitReader {
|
||||
unit: encrypted_audio_unit(&unit_key),
|
||||
capacity: 2048,
|
||||
});
|
||||
let mut title = aac_audio_title(0x1100);
|
||||
title.extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 3,
|
||||
}];
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, unit_key)],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
|
||||
let opts = MuxOptions {
|
||||
skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill
|
||||
batch_sectors: 3, // one aligned unit per read
|
||||
raw: false,
|
||||
send_deadline: Some(Duration::from_secs(60)),
|
||||
};
|
||||
let halt = Halt::new();
|
||||
let out = mux_stream(
|
||||
MuxInput::Live {
|
||||
reader,
|
||||
title,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
keys,
|
||||
key_map: None, // plain AACS disc: the driver must resolve the base map
|
||||
},
|
||||
"null://",
|
||||
&opts,
|
||||
&halt,
|
||||
Arc::new(NoopEvents),
|
||||
)
|
||||
.expect(
|
||||
"a plain AACS live mux must resolve+install its base key map and DECRYPT \
|
||||
(no map → the content batch fails to decrypt and the mux aborts)",
|
||||
);
|
||||
assert!(
|
||||
out.completed,
|
||||
"the decrypted audio PES drained and finalised — proves the unit decrypted"
|
||||
);
|
||||
assert!(
|
||||
out.bytes_written > 0,
|
||||
"decrypted payload bytes reached the sink"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shared `resolve_inline_base_map` helper's gating: an AACS key set
|
||||
/// yields a map (Some); CSS/clear/None and `raw` yield None (CSS self-cracks
|
||||
/// in `DiscStream::new`; raw is ciphertext passthrough). Guards the Session
|
||||
/// and Live arms against accidentally mapping a DVD (which would suppress the
|
||||
/// per-title CSS crack) or resolving under `--raw`.
|
||||
#[test]
|
||||
fn resolve_inline_base_map_gates_on_aacs_and_raw() {
|
||||
use crate::disc::Extent;
|
||||
let unit_key = [0x5Au8; 16];
|
||||
let mut title = aac_audio_title(0x1100);
|
||||
title.extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 3,
|
||||
}];
|
||||
let mk_reader = || AacsUnitReader {
|
||||
unit: encrypted_audio_unit(&unit_key),
|
||||
capacity: 2048,
|
||||
};
|
||||
|
||||
// AACS, not raw → a map is resolved and installed.
|
||||
let mut r = mk_reader();
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, unit_key)],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let map = resolve_inline_base_map(
|
||||
&mut r,
|
||||
&title,
|
||||
&mut keys,
|
||||
None,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
false,
|
||||
)
|
||||
.expect("resolve must not error for a single-CPS AACS disc");
|
||||
assert!(map.is_some(), "AACS non-raw must resolve a base map");
|
||||
|
||||
// AACS but raw → no map (ciphertext passthrough).
|
||||
let mut r = mk_reader();
|
||||
let mut keys_raw = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, unit_key)],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let map_raw = resolve_inline_base_map(
|
||||
&mut r,
|
||||
&title,
|
||||
&mut keys_raw,
|
||||
None,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
true,
|
||||
)
|
||||
.expect("raw resolve is a no-op");
|
||||
assert!(map_raw.is_none(), "raw must NOT resolve a map");
|
||||
|
||||
// CSS / clear (DecryptKeys::None) → no map (CSS self-cracks per title).
|
||||
let mut r = mk_reader();
|
||||
let mut keys_none = DecryptKeys::None;
|
||||
let map_none = resolve_inline_base_map(
|
||||
&mut r,
|
||||
&title,
|
||||
&mut keys_none,
|
||||
None,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
)
|
||||
.expect("clear/CSS resolve is a no-op");
|
||||
assert!(map_none.is_none(), "CSS/clear must NOT resolve an AACS map");
|
||||
}
|
||||
|
||||
// ── Regression A: header-buffer cap fails fast instead of OOM ───────────
|
||||
//
|
||||
// A stream whose headers never resolve but that keeps yielding frames must
|
||||
|
||||
Reference in New Issue
Block a user