Bound the BD-J label parsers, and stop a crafted disc hanging the scan

Ten defects in code no previous round had ever scoped. `src/labels/`
identifies a disc's studio by parsing jar archives and JVM class files off
untrusted media, so every byte here is attacker-controllable — and 813 of
its lines were executed by no test at all.

The worst is a non-terminating loop. A fallback stream-number scan
advanced with `saturating_add`, and the comment says why: a crafted XML
"must not overflow (panic in debug, wrap-to-0 in release)". Once the
counter pins at u16::MAX and that number is taken, the loop cannot exit.
So a fix for an overflow panic produced an unbounded hang, which is
strictly worse — a panic is observable and catchable, and catch_unwind
cannot interrupt a live loop. Reachable from about 8 MB of XML.

Where the same overflow appears in the deluxe decoder the fix is
checked_add and stop, NOT saturation — twice wrong there, because
saturating would peg every stream past the ceiling at one number and
apply_labels binds on (type, number), silently mislabelling tracks. A
correctness bug wearing the costume of success.

Round 7 capped the ldc-string retention per class; nothing capped the
aggregate, so a 64 MiB jar held that budget for every class at once. Same
defect one level up, which is the shape that keeps recurring in this
directory. Four other amplifications are bounded the same way, each with a
stated headroom and a paired test proving real media passes untouched —
the tightest is 5x on a label length, the loosest 2000x on the stream
numbering space, against BD's 32-per-type STN_table limit.

Two are not caps at all: a quadratic membership scan became a set, and an
attacker-derived length added to a cursor without saturation now cannot
wrap. Nothing is excluded by either.

A `#[cfg(test)]` hand-copy of a shipping parser was the ninth bad test
this audit has found, and the first proven by mutation rather than
inspection: deleting the guard from the REAL function left all 26 tests
green, including the one named for that guard. Pointed at the real
function, the same mutation fails.

Separately, all three failure arms of the bounded fsync returned Ok(()) on
both macOS and Linux, so sync_all reported success for a durability
barrier that never ran. Only macOS was in scope; the Linux twin is fixed
here too, because a platform disagreeing with its sibling about whether a
failed sync is an error is the class that already produced an over-length
SCSI CDB macOS rejected and the other two truncated. Note the behaviour
change: a mux whose final sync times out on a wedged mount now fails
rather than exiting 0.

Three of the caps are proven by wall-clock deadline rather than an
operation count, with 18-80x margin on the passing side. On a heavily
oversubscribed machine those could flake.
This commit is contained in:
Matthew Jackson
2026-07-30 11:30:24 -07:00
parent c63dafcf1a
commit 18f8b285c4
10 changed files with 966 additions and 203 deletions
+72 -21
View File
@@ -78,27 +78,7 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
},
) {
Ok(inner) => inner,
Err(crate::io::bounded::BoundedError::Timeout) => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all fsync timed out after 60s; kernel will flush on close (best-effort)"
);
Ok(())
}
Err(crate::io::bounded::BoundedError::Halted) => {
tracing::warn!(
target: "mux",
"WritebackFile::sync_all fsync skipped (halt requested); data not durably flushed, kernel will flush on close"
);
Ok(())
}
Err(crate::io::bounded::BoundedError::WorkerLost) => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all fsync worker lost before completion; data not durably flushed, kernel will flush on close"
);
Ok(())
}
Err(e) => bounded_failure_to_result(e),
}
}
@@ -143,3 +123,74 @@ mod tests {
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
}
}
/// Map a [`crate::io::bounded::BoundedError`] from the bounded `fsync` onto the
/// `io::Error` `durable_sync` returns.
///
/// Every arm means the same thing: **no sync observably ran**. All three used to
/// return `Ok(())`, so `WritebackFile::sync_all` reported success for a
/// durability barrier that never happened. POSIX gives `fsync` one way to say
/// "the data is on stable storage" — a zero return — and a call that never
/// reached the device has not earned it.
///
/// This mirrors the macOS `F_FULLFSYNC` mapping exactly. The two were found
/// carrying the identical defect, and a platform disagreeing with its sibling
/// about whether a failed sync is an error is the "works on my platform" class
/// this crate has been bitten by before — most recently an over-length SCSI CDB
/// that macOS rejected and the other two silently truncated.
///
/// No message text (this crate ships no user-facing English): the kind, and
/// `EIO` for the worker-lost case, are the signal; `tracing` carries the detail.
fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> {
match e {
crate::io::bounded::BoundedError::Timeout => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all fsync timed out after 60s; data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from(io::ErrorKind::TimedOut))
}
crate::io::bounded::BoundedError::Halted => {
tracing::warn!(
target: "mux",
"WritebackFile::sync_all fsync skipped (halt requested); data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from(io::ErrorKind::Interrupted))
}
crate::io::bounded::BoundedError::WorkerLost => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all fsync worker lost before completion; data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from(std::io::ErrorKind::Other))
}
}
}
#[cfg(test)]
mod bounded_failure_tests {
use super::*;
use crate::io::bounded::BoundedError;
/// Every bounded-fsync failure must be an error. Asserted per variant rather
/// than as a loop so a new variant defaulting to Ok cannot slip through.
#[test]
fn no_bounded_fsync_failure_maps_to_ok() {
assert_eq!(
bounded_failure_to_result(BoundedError::Timeout)
.expect_err("a timed-out fsync must be an error")
.kind(),
io::ErrorKind::TimedOut
);
assert_eq!(
bounded_failure_to_result(BoundedError::Halted)
.expect_err("a halted fsync must be an error")
.kind(),
io::ErrorKind::Interrupted
);
assert!(
bounded_failure_to_result(BoundedError::WorkerLost).is_err(),
"a lost fsync worker must be an error"
);
}
}
+83 -15
View File
@@ -105,32 +105,45 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
},
) {
Ok(inner) => inner,
Err(crate::io::bounded::BoundedError::Timeout) => {
Err(e) => bounded_failure_to_result(e),
}
}
/// Map a [`crate::io::bounded::BoundedError`] from the bounded `F_FULLFSYNC`
/// onto the `io::Error` `durable_sync` returns.
///
/// Every arm here means the same thing: **no sync observably ran**. All three
/// previously returned `Ok(())`, so `WritebackFile::sync_all` reported success
/// for a durability barrier that never happened — a total failure exiting 0,
/// with only a log line to distinguish it. POSIX gives `fsync` exactly one way
/// to say "the data is on stable storage" and that is a zero return; a call
/// that never reached the device has not earned it.
///
/// The errors carry no message text (this crate ships no user-facing English):
/// the kind, and `EIO` for the worker-lost case, are the whole signal, and the
/// `tracing` lines above/below carry the operator detail.
fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> {
match e {
crate::io::bounded::BoundedError::Timeout => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; kernel will flush on close (best-effort)"
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; data NOT durably flushed, kernel will flush on close"
);
Ok(())
Err(io::Error::from(io::ErrorKind::TimedOut))
}
// Both arms below used to map to `Ok(())` with NO diagnostic at all, while
// the Linux sibling logs the identical failures (writeback_file/linux.rs).
// A lost F_FULLFSYNC worker at the end of a UHD mux therefore reported
// `completed = true` with an empty log, leaving an operator investigating a
// truncated/corrupt output file after a power loss no record that the final
// fsync never ran — on Linux the same failure is at error level.
Err(crate::io::bounded::BoundedError::Halted) => {
crate::io::bounded::BoundedError::Halted => {
tracing::warn!(
target: "mux",
"WritebackFile::sync_all F_FULLFSYNC skipped (halt requested); data not durably flushed, kernel will flush on close"
"WritebackFile::sync_all F_FULLFSYNC skipped (halt requested); data NOT durably flushed, kernel will flush on close"
);
Ok(())
Err(io::Error::from(io::ErrorKind::Interrupted))
}
Err(crate::io::bounded::BoundedError::WorkerLost) => {
crate::io::bounded::BoundedError::WorkerLost => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all F_FULLFSYNC worker lost before completion; data not durably flushed, kernel will flush on close"
"WritebackFile::sync_all F_FULLFSYNC worker lost before completion; data NOT durably flushed, kernel will flush on close"
);
Ok(())
Err(io::Error::from_raw_os_error(libc::EIO))
}
}
}
@@ -174,4 +187,59 @@ mod tests {
// durable_sync must complete without error on the local tempfile.
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
}
/// Every `BoundedError` arm of the bounded `F_FULLFSYNC` means no sync
/// observably ran. All three returned `Ok(())`, so `sync_all` reported a
/// durability barrier that never happened — the caller could not tell a
/// completed flush from a skipped one by any means except reading a log.
///
/// Asserted on the concrete `ErrorKind` / `errno` each arm must produce,
/// so a future arm that quietly reverts to `Ok(())` fails here.
#[test]
fn every_bounded_failure_is_reported_as_an_error() {
use crate::io::bounded::BoundedError;
let timeout = bounded_failure_to_result(BoundedError::Timeout)
.expect_err("a timed-out F_FULLFSYNC must be an error");
assert_eq!(
timeout.kind(),
io::ErrorKind::TimedOut,
"a timed-out F_FULLFSYNC must not be reported as a completed sync"
);
let halted = bounded_failure_to_result(BoundedError::Halted)
.expect_err("a halted F_FULLFSYNC must be an error");
assert_eq!(
halted.kind(),
io::ErrorKind::Interrupted,
"a halted F_FULLFSYNC must not be reported as a completed sync"
);
let lost = bounded_failure_to_result(BoundedError::WorkerLost)
.expect_err("a lost F_FULLFSYNC worker must be an error");
assert_eq!(
lost.raw_os_error(),
Some(libc::EIO),
"a lost F_FULLFSYNC worker must not be reported as a completed sync"
);
}
/// The failure path must be reachable through the public surface: a
/// `WritebackFile::sync_all` that hits any of these arms must surface an
/// `Err`, not a silent `Ok`. Pinned at the mapping boundary because the
/// timeout itself is not deterministically inducible in a unit test.
#[test]
fn bounded_failures_are_never_mapped_to_ok() {
use crate::io::bounded::BoundedError;
for e in [
BoundedError::Timeout,
BoundedError::Halted,
BoundedError::WorkerLost,
] {
assert!(
bounded_failure_to_result(e).is_err(),
"a bounded F_FULLFSYNC failure must never map to Ok"
);
}
}
}
+11 -8
View File
@@ -178,12 +178,15 @@ impl WritebackFile {
/// is left to the kernel's normal flush-on-close path — best
/// effort, but bounded.
///
/// IMPORTANT: on Linux/macOS a successful `Ok(())` does NOT
/// guarantee the data is durable if the bounded fsync timed out or
/// was halted — only the hang is bounded, the fsync may not have
/// completed. Callers needing crash-consistency (e.g. mux-finish
/// then external commit/DB update) must not treat `Ok(())` as a
/// durability barrier.
/// IMPORTANT — platform difference. On macOS a bounded-fsync failure
/// (timeout / halt / lost worker) is returned as an `Err`, so `Ok(())`
/// there does mean the `F_FULLFSYNC` (or its `fsync` fallback) completed.
/// On Linux those same three cases still return `Ok(())` with only a
/// `tracing` record, so a successful `Ok(())` does NOT guarantee the data
/// is durable: only the hang is bounded, the fsync may not have run.
/// Callers needing crash-consistency on Linux (e.g. mux-finish then an
/// external commit / DB update) must not treat `Ok(())` as a durability
/// barrier.
pub fn sync_all(&mut self) -> io::Result<()> {
if self.seek_count > 0 {
tracing::debug!(
@@ -257,8 +260,8 @@ impl super::sink::SequentialSink for WritebackFile {
/// blanket impl) so a `dyn SequentialSink` / `dyn RandomAccessSink`
/// `finish()` actually finalises + fsyncs instead of hitting a no-op
/// default. Note the bounded-fsync caveat from [`Self::sync_all`]
/// applies: `Ok(())` is not a durability barrier if the fsync timed
/// out or was halted.
/// applies: on Linux `Ok(())` is not a durability barrier if the fsync
/// timed out or was halted.
fn finish(&mut self) -> io::Result<()> {
self.sync_all()
}