Files
libfreemkv/docs/mux-disc.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

95 lines
4.7 KiB
Markdown

# src/mux/disc.rs — internal notes
Overflow rationale for internal (non-`pub`) items in `src/mux/disc.rs`,
relocated here by the comment-guard so the in-file comment can stay short.
Each section is pointed to by a one-line `// See docs/mux-disc.md — <topic>`
comment at the corresponding call site.
## commit_read
Commits a SUCCESSFUL `read_sectors` into the read buffer and advances the
extent cursor. `got` is the byte count the source reported (already clamped
to `bytes`, the full span requested for `sectors`).
The full case (`got == bytes`) is the only one every in-tree `SectorSource`
produces — they are all full-or-error — and it behaves exactly as before.
A SHORT read (`got < bytes`) used to be handled inconsistently: the returned
count was trusted for `buf_valid`, but `current_offset` still advanced by the
full requested `sectors`. The undelivered tail therefore vanished from the
muxed title with no error, no `SectorSkipped` event and no `lost_bytes` — a
silent hole, indistinguishable from clean output, in a single-pass path that
has no later pass to recover it. It is handled here rather than trusted away
because invisible data loss is the one outcome this stream must never
produce.
The tail is NOT re-read from a smaller offset: `current_offset` must stay on
an AACS unit boundary (`unit_align`), and resuming mid-unit desyncs the
decrypt of everything after it — the same reason the failed-unit skip branch
below advances by the whole unit. So the gap is either
- a hard [`crate::error::Error::DiscRead`] (E6000) when the caller has NOT
opted into holes, or
- zero-filled and ACCOUNTED under `skip_errors`, identically to a failed
unit: the stale buffer tail is cleared, `errors` / `lost_bytes` are
charged, and a `SectorSkipped` event is emitted.
## a_long_run_of_empty_extents_does_not_recurse_per_extent
Nothing filters `sector_count == 0` out of a UDF/MPLS extent list, so a
malformed disc can declare a long run of empty extents. `fill_extents` used
to skip each one with a self-recursive call, costing a stack frame apiece;
Rust does not guarantee tail-call elimination, so a few thousand of them
overflowed the stack. A stack overflow aborts the process — it is not
catchable and takes the whole `autorip` service down — where the iterative
form just walks off the end and reports EOF.
Run on a deliberately small stack so the frame cost is unmissable: the
recursive version dies here, the loop finishes in microseconds.
## bad_sector_keeps_its_identity_across_the_prefetch_channel
REGRESSION (round-4 audit): an ordinary MEDIUM ERROR bad sector must keep its
identity when it crosses the prefetch producer channel — the same `DiscRead`
with its SCSI status, NOT a transport failure.
`PrefetchedSectorSource::read_sectors` re-wrapped every error that crossed
the channel as `Error::IoError`, and `is_scsi_transport_failure` matches
`IoError` (the wedged-USB-bridge arm). So a bad sector reached `fill_extents`
looking like a dead bus and aborted the pass with a fabricated status 0xFF —
the exact inverse of what that short-circuit exists for, and it told the
user to power-cycle a healthy drive.
Asserted on the source, not on a `fill_extents` skip: the producer thread
exits for good after sending an error, so nothing downstream of it can
genuinely recover the rest of the title (see
`dead_prefetch_producer_does_not_silently_zero_fill_the_title`). An assertion
that the pass continues could only ever have been satisfied by fabricated
zeros.
## dead_prefetch_producer_does_not_silently_zero_fill_the_title
The prefetch producer thread terminates PERMANENTLY on its first read error,
so once one bad sector has crossed the channel the source can never deliver
another byte. Driving `fill_extents` to exhaustion after that must NOT look
like a completed pass: every remaining sector would be fabricated zeros, and
DATA LOSS MUST NEVER LOOK LIKE SUCCESS.
The expectation is the product rule, not the code: a source that is
permanently out of data must report that, not answer `Ok(0)` forever — which
`commit_read` legitimately reads as an ordinary short read and zero-fills.
## errors_reports_frames_the_resync_gate_dropped_after_the_gap_resolves
Frames the B1 resync gate discards must reach `errors()`, including after
the gap RESOLVES.
`ResyncGate::dropped` is zeroed the moment a keyframe disarms the gate, and
the only EOF warning fires for gates STILL armed — so a mid-title gap that
resolves left no trace anywhere. That is the common case: most gaps do
resolve. A rip with several concealed gaps reported 0 errors and 0 lost
bytes while whole GOPs were discarded.
This asserts the accessor, not the gate's own counter, because `errors()` is
the only channel through which a caller learns anything went wrong.