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
+64 -28
View File
@@ -90,12 +90,18 @@ pub fn save(data: &[u8]) -> Result<UpdateResult> {
let path = default_path()?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|_| Error::KeydbWrite {
path: path.display().to_string(),
std::fs::create_dir_all(dir).map_err(|e| {
tracing::warn!(error = %e, path = %path.display(), "keydb dir create failed");
Error::KeydbWrite {
path: path.display().to_string(),
}
})?;
}
std::fs::write(&path, &text).map_err(|_| Error::KeydbWrite {
path: path.display().to_string(),
std::fs::write(&path, &text).map_err(|e| {
tracing::warn!(error = %e, path = %path.display(), "keydb write failed");
Error::KeydbWrite {
path: path.display().to_string(),
}
})?;
Ok(UpdateResult {
@@ -125,8 +131,10 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
.ok()
.and_then(|mut it| it.next())
.ok_or_else(|| Error::KeydbConnect { host: host.clone() })?;
let mut stream = TcpStream::connect_timeout(&addr, NET_TIMEOUT)
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
let mut stream = TcpStream::connect_timeout(&addr, NET_TIMEOUT).map_err(|e| {
tracing::debug!(error = %e, host = %host, "keydb connect failed");
Error::KeydbConnect { host: host.clone() }
})?;
stream
.set_read_timeout(Some(READ_TIMEOUT))
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
@@ -144,27 +152,45 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
.write_all(request.as_bytes())
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
let mut response = Vec::new();
stream
.take(100 * 1024 * 1024)
.read_to_end(&mut response)
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
let header_end = find_header_end(&response).ok_or(Error::KeydbParse)?;
// Read the header block incrementally up to the \r\n\r\n terminator,
// bounded to ~64 KiB, BEFORE pulling any body. This avoids buffering up
// to 100 MiB per redirect hop just to inspect the status / Location.
const MAX_HEADER_BYTES: usize = 64 * 1024;
let mut reader = std::io::BufReader::new(stream);
let mut header_buf: Vec<u8> = Vec::with_capacity(1024);
let mut byte = [0u8; 1];
loop {
let n = reader
.read(&mut byte)
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
if n == 0 {
// Connection closed before headers completed.
return Err(Error::KeydbParse);
}
header_buf.push(byte[0]);
if header_buf.ends_with(b"\r\n\r\n") {
break;
}
if header_buf.len() > MAX_HEADER_BYTES {
return Err(Error::KeydbParse);
}
}
// header_buf includes the trailing \r\n\r\n.
let header_end = header_buf.len() - 4;
// Lossy: a stray non-UTF-8 byte in the header block must not blank
// out the whole status line / Location header (which would surface
// as an undiagnosable KeydbHttp{status:0}).
let headers = String::from_utf8_lossy(&response[..header_end]);
let body = &response[header_end + 4..];
let headers = String::from_utf8_lossy(&header_buf[..header_end]).into_owned();
let headers = headers.as_str();
let status = parse_status(&headers);
let status = parse_status(headers).ok_or(Error::KeydbParse)?;
// Only treat a Location header as a redirect when the status is
// actually 3xx; a 200 carrying a stray Location (some proxies) is
// not a redirect, and a 3xx without Location is a malformed redirect.
if (300..=399).contains(&status) {
let location =
extract_header(&headers, "Location").ok_or(Error::KeydbHttp { status })?;
extract_header(headers, "Location").ok_or(Error::KeydbHttp { status })?;
let (next_host, next_port, next_path) = resolve_redirect(&location, &host, port)?;
host = next_host;
port = next_port;
@@ -176,7 +202,14 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
return Err(Error::KeydbHttp { status });
}
return Ok(body.to_vec());
// Now read the body, still bounded by the existing 100 MiB cap. The
// BufReader carries any bytes already buffered past the header.
let mut body = Vec::new();
reader
.take(100 * 1024 * 1024)
.read_to_end(&mut body)
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
return Ok(body);
}
Err(Error::KeydbTooManyRedirects)
@@ -244,15 +277,18 @@ fn parse_url(url: &str) -> Result<(String, u16, String)> {
Ok((host.to_string(), port, path.to_string()))
}
fn parse_status(headers: &str) -> u16 {
fn parse_status(headers: &str) -> Option<u16> {
headers
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
/// Locate the end of the HTTP header block (the index of the `\r\n\r\n`).
/// Retained for the framing unit tests; the live path now reads headers
/// incrementally in `http_get` so the whole response is never buffered.
#[cfg_attr(not(test), allow(dead_code))]
fn find_header_end(data: &[u8]) -> Option<usize> {
data.windows(4).position(|w| w == b"\r\n\r\n")
}
@@ -357,9 +393,9 @@ mod tests {
#[test]
fn parse_status_extracts_code() {
assert_eq!(parse_status("HTTP/1.0 200 OK\r\nFoo: bar"), 200);
assert_eq!(parse_status("HTTP/1.1 301 Moved Permanently"), 301);
assert_eq!(parse_status("garbage"), 0);
assert_eq!(parse_status("HTTP/1.0 200 OK\r\nFoo: bar"), Some(200));
assert_eq!(parse_status("HTTP/1.1 301 Moved Permanently"), Some(301));
assert_eq!(parse_status("garbage"), None);
}
// ── New comprehensive tests ────────────────────────────────────────────────
@@ -552,12 +588,12 @@ mod tests {
assert!(result.is_ok(), "exactly MAX_KEYDB_BYTES must be accepted");
}
/// parse_status returns 0 for an empty status line (not a panic).
/// Mutation: calling unwrap() instead of unwrap_or(0) panics on empty input.
/// parse_status returns None for an empty/malformed status line (not a
/// meaningless 0). The call site maps None to Error::KeydbParse.
#[test]
fn parse_status_empty_input_returns_0() {
assert_eq!(parse_status(""), 0);
assert_eq!(parse_status("\r\n"), 0);
fn parse_status_empty_input_returns_none() {
assert_eq!(parse_status(""), None);
assert_eq!(parse_status("\r\n"), None);
}
/// Regression: set_read_timeout / set_write_timeout failures must surface as