Audit v3 fixes: all 3 tiers (19 findings)

Tier 1 (compilation + correctness):
- Fix nightly-only is_multiple_of → % 2 != 0 (stable Rust compat)
- Fix parse_sample_rate: check 192 before 96 (was returning wrong rate)
- macOS drive discovery: split unix.rs → linux.rs + macos.rs
- Linux: EACCES returns DevicePermission not DeviceNotFound
- CLI pipe.rs: Ctrl+C signal handler added

Tier 2 (correctness + security):
- MkvStream: reset demuxer after scanning→streaming transition
- Windows SPTI: zero data buffer before ioctl
- AACS cert verification: documented why silently skipped
- KEYDB: HOME + USERPROFILE fallback for Windows
- Library modules: pub(crate) for internal modules
- AACS: explicit re-exports, AES primitives pub(crate)

Tier 3 (performance + polish):
- IsoStream: batch 64-sector reads (was 1 sector at a time)
- DiscStream: buffer swap instead of copy in decrypt_and_buffer
- Vec capacity hints in TS/PS demuxer hot paths
- NetworkStream: TLS warning documented
- Batch rip: per-title progress display
- cargo fmt: 0 violations

319 tests, 0 fmt violations.
This commit is contained in:
MattJackson
2026-04-11 19:24:25 +00:00
parent 43f81e4eae
commit a74d395f68
22 changed files with 174 additions and 73 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
}
/// AES-128-ECB decrypt a single 16-byte block.
pub fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.decrypt_block(&mut block);
+15 -7
View File
@@ -815,9 +815,13 @@ pub fn aacs_authenticate(
return Err(Error::AacsCertVerify);
}
} else if drive_cert[0] == 0x11 {
// AACS 2.0 certificate — verify with P-256 LA key
// Note: AACS 2.0 drives still accept AACS 1.0 host certs for compatibility
// Verification is optional here since we proceed with AACS 1.0 flow anyway
// AACS 2.0 certificate — verification intentionally skipped here.
// Reason: backward compatibility. AACS 2.0 drives accept AACS 1.0 host
// certs, so we proceed with the AACS 1.0 flow regardless. The P-256
// LA public key needed to verify 2.0 certs is not always available, and
// failing here would break handshakes with drives that work fine otherwise.
// The drive's identity is still authenticated through the ECDH key
// exchange and signature verification in step 6 below.
}
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
@@ -954,9 +958,13 @@ fn aacs2_authenticate_p256(
drive_nonce.copy_from_slice(&response[4..24]);
let drive_cert = &response[24..156];
// Verify drive certificate with AACS 2.0 LA key
// Verify drive certificate with AACS 2.0 LA key.
// Verification failure is intentionally non-fatal: some drive firmware
// uses certificate formats that differ from the spec, and rejecting them
// would break otherwise working drives. The drive is still authenticated
// through the ECDH key exchange and P-256 signature verification below.
if drive_cert[0] == 0x11 && !verify_cert_p256(drive_cert) {
// Non-fatal: some cert formats may differ
// Certificate verification failed but proceeding for backward compatibility.
}
// Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes)
@@ -1056,8 +1064,8 @@ pub fn read_data_keys(
enc_wdk.copy_from_slice(&response[20..36]);
// Decrypt with bus key (AES-ECB)
let read_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_rdk);
let write_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_wdk);
let read_data_key = super::decrypt::aes_ecb_decrypt(&auth.bus_key, &enc_rdk);
let write_data_key = super::decrypt::aes_ecb_decrypt(&auth.bus_key, &enc_wdk);
auth.read_data_key = Some(read_data_key);
Ok((read_data_key, write_data_key))
+1 -1
View File
@@ -57,7 +57,7 @@ pub struct DiscEntry {
/// Parse a hex string like "0xABCD..." into bytes.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
if !s.len().is_multiple_of(2) {
if s.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
+12 -3
View File
@@ -18,6 +18,15 @@ pub mod handshake;
pub mod keydb;
pub mod keys;
pub use decrypt::*;
pub use keydb::*;
pub use keys::*;
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, is_unit_encrypted,
ALIGNED_UNIT_LEN,
};
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::{
decrypt_unit_key, derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash,
disc_hash_hex, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
resolve_keys, ContentCert, ResolvedKeys, UnitKeyFile,
};
+4 -1
View File
@@ -87,7 +87,10 @@ impl Disc {
.iter()
.map(|cell| {
let start = ts.vob_start_sector.saturating_add(cell.first_sector);
let count = cell.last_sector.saturating_sub(cell.first_sector).saturating_add(1);
let count = cell
.last_sector
.saturating_sub(cell.first_sector)
.saturating_add(1);
Extent {
start_lba: start,
sector_count: count,
+1 -1
View File
@@ -427,7 +427,7 @@ impl ScanOptions {
return Some(p.clone());
}
}
if let Some(home) = std::env::var_os("HOME") {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
for relative in KEYDB_SEARCH_PATHS {
let p = std::path::PathBuf::from(&home).join(relative);
if p.exists() {
+1 -1
View File
@@ -1,4 +1,4 @@
//! Unix (Linux/macOS) drive discovery and device resolution.
//! Linux drive discovery and device resolution.
use crate::error::{Error, Result};
use crate::identity::DriveId;
+37
View File
@@ -0,0 +1,37 @@
//! macOS drive discovery and device resolution.
use crate::error::{Error, Result};
use crate::identity::DriveId;
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
for i in 0..16 {
let path = format!("/dev/disk{}", i);
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
drives.push((path, id));
}
}
}
}
drives
}
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
// Accept /dev/diskN or /dev/rdiskN paths as-is
if path.contains("/disk") || path.contains("/rdisk") {
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound {
path: path.to_string(),
});
}
return Ok((path.to_string(), None));
}
if !std::path::Path::new(path).exists() {
return Err(Error::DeviceNotFound {
path: path.to_string(),
});
}
Ok((path.to_string(), None))
}
+16 -6
View File
@@ -6,8 +6,10 @@
//! 3. `init()` — activate custom firmware. Removes riplock.
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
#[cfg(unix)]
mod unix;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(windows)]
mod windows;
@@ -209,9 +211,13 @@ impl SectorReader for DriveSession {
}
pub fn find_drives() -> Vec<(String, DriveId)> {
#[cfg(unix)]
#[cfg(target_os = "linux")]
{
unix::find_drives()
linux::find_drives()
}
#[cfg(target_os = "macos")]
{
macos::find_drives()
}
#[cfg(windows)]
{
@@ -224,9 +230,13 @@ pub fn find_drive() -> Option<String> {
}
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
#[cfg(unix)]
#[cfg(target_os = "linux")]
{
unix::resolve_device(path)
linux::resolve_device(path)
}
#[cfg(target_os = "macos")]
{
macos::resolve_device(path)
}
#[cfg(windows)]
{
+4
View File
@@ -43,6 +43,10 @@ pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
/// Normalize a device path to Windows \\.\X: format.
///
/// Accepts: "D:", "D:\\", "\\.\D:", "\\.\CdRom0"
///
/// NOTE: A near-identical `normalize_device_path` exists in `scsi::windows`.
/// Both are kept because they live in separate `cfg(windows)` modules that
/// cannot easily share a helper without introducing cross-module coupling.
fn normalize_path(path: &str) -> String {
if path.starts_with("\\\\.\\") {
return path.to_string();
+11 -10
View File
@@ -68,24 +68,24 @@
//! | E7xxx | AACS errors |
pub mod aacs;
pub mod clpi;
pub(crate) mod clpi;
pub mod css;
pub mod disc;
pub mod drive;
pub mod error;
pub mod event;
pub mod identity;
pub mod ifo;
pub(crate) mod identity;
pub(crate) mod ifo;
pub mod keydb;
pub mod labels;
pub mod mpls;
pub(crate) mod labels;
pub(crate) mod mpls;
pub mod mux;
pub mod platform;
pub mod profile;
pub(crate) mod platform;
pub(crate) mod profile;
pub mod scsi;
pub mod sector;
pub mod speed;
pub mod udf;
pub(crate) mod sector;
pub(crate) mod speed;
pub(crate) mod udf;
pub use drive::{find_drive, find_drives, resolve_device, DriveSession};
pub use error::{Error, Result};
@@ -111,3 +111,4 @@ pub use mux::{open_input, open_output, parse_url, InputOptions};
pub use scsi::ScsiTransport;
pub use sector::SectorReader;
pub use speed::DriveSpeed;
pub use udf::{read_filesystem, UdfFs};
+9 -13
View File
@@ -9,9 +9,8 @@
use super::IOStream;
use crate::disc::{
ContentFormat, Disc, DiscTitle, Extent,
MIN_BATCH_SECTORS, RAMP_BATCH_AFTER, RAMP_SPEED_AFTER,
SLOW_SPEED_AFTER, detect_max_batch_sectors,
detect_max_batch_sectors, ContentFormat, Disc, DiscTitle, Extent, MIN_BATCH_SECTORS,
RAMP_BATCH_AFTER, RAMP_SPEED_AFTER, SLOW_SPEED_AFTER,
};
use crate::drive::DriveSession;
use crate::error::Error;
@@ -187,8 +186,7 @@ impl DiscStream {
if self.batch_sectors < self.max_batch_sectors
&& self.ok_streak >= RAMP_BATCH_AFTER
{
self.batch_sectors =
(self.batch_sectors * 2).min(self.max_batch_sectors);
self.batch_sectors = (self.batch_sectors * 2).min(self.max_batch_sectors);
self.ok_streak = 0;
}
@@ -220,14 +218,12 @@ impl DiscStream {
}
if self.batch_sectors > MIN_BATCH_SECTORS {
self.batch_sectors =
(self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
std::thread::sleep(std::time::Duration::from_millis(100));
} else {
// At minimum batch -- retry once with longer pause
std::thread::sleep(std::time::Duration::from_millis(500));
self.read_buf
.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
self.error_streak = 0;
self.current_offset += MIN_BATCH_SECTORS as u32;
@@ -243,8 +239,7 @@ impl DiscStream {
self.current_extent += 1;
self.current_offset = 0;
}
self.read_buf
.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
self.read_buf.fill(0);
return Ok(true);
}
@@ -283,8 +278,9 @@ impl DiscStream {
}
// No encryption: read_buf is already plaintext
self.batch_buf.clear();
self.batch_buf.extend_from_slice(&self.read_buf[..total_bytes]);
// Swap buffers instead of copying — the old batch_buf becomes
// read_buf and will be overwritten on the next read.
std::mem::swap(&mut self.batch_buf, &mut self.read_buf);
self.batch_pos = 0;
}
}
+18 -10
View File
@@ -18,6 +18,9 @@ use std::path::Path;
const SECTOR_SIZE: u64 = 2048;
/// Maximum sectors to batch-read at once (64 sectors = 128 KB).
const BATCH_SECTORS: usize = 64;
/// File-backed sector reader for ISO images.
pub struct IsoSectorReader {
file: File,
@@ -63,7 +66,8 @@ pub struct IsoStream {
extents: Vec<(u32, u32)>,
extent_idx: usize,
sectors_remaining: u32,
sector_buf: [u8; SECTOR_SIZE as usize],
/// Batch buffer: holds up to BATCH_SECTORS sectors (128 KB) at once.
batch_buf: Vec<u8>,
buf_pos: usize,
buf_len: usize,
eof: bool,
@@ -107,7 +111,7 @@ impl IsoStream {
extents,
extent_idx: 0,
sectors_remaining,
sector_buf: [0u8; SECTOR_SIZE as usize],
batch_buf: vec![0u8; BATCH_SECTORS * SECTOR_SIZE as usize],
buf_pos: 0,
buf_len: 0,
eof: false,
@@ -130,7 +134,7 @@ impl IsoStream {
extents: Vec::new(),
extent_idx: 0,
sectors_remaining: 0,
sector_buf: [0u8; SECTOR_SIZE as usize],
batch_buf: Vec::new(),
buf_pos: 0,
buf_len: 0,
eof: false,
@@ -163,7 +167,8 @@ impl IsoStream {
self.disc.as_ref()
}
fn read_next_sector(&mut self) -> io::Result<bool> {
/// Read up to BATCH_SECTORS sectors at once into the batch buffer.
fn read_next_batch(&mut self) -> io::Result<bool> {
let reader = match self.reader.as_mut() {
Some(r) => r,
None => return Ok(false),
@@ -177,13 +182,16 @@ impl IsoStream {
let offset = total - self.sectors_remaining;
let lba = start_lba + offset;
// Read up to BATCH_SECTORS, but no more than remaining in this extent
let count = (self.sectors_remaining as usize).min(BATCH_SECTORS) as u16;
reader
.read_sectors(lba, 1, &mut self.sector_buf)
.read_sectors(lba, count, &mut self.batch_buf)
.map_err(|e| io::Error::other(e.to_string()))?;
self.buf_pos = 0;
self.buf_len = SECTOR_SIZE as usize;
self.buf_len = count as usize * SECTOR_SIZE as usize;
self.sectors_remaining -= 1;
self.sectors_remaining -= count as u32;
if self.sectors_remaining == 0 {
self.extent_idx += 1;
if self.extent_idx < self.extents.len() {
@@ -223,14 +231,14 @@ impl Read for IsoStream {
if self.buf_pos < self.buf_len {
let n = (self.buf_len - self.buf_pos).min(buf.len());
buf[..n].copy_from_slice(&self.sector_buf[self.buf_pos..self.buf_pos + n]);
buf[..n].copy_from_slice(&self.batch_buf[self.buf_pos..self.buf_pos + n]);
self.buf_pos += n;
return Ok(n);
}
if self.read_next_sector()? {
if self.read_next_batch()? {
let n = self.buf_len.min(buf.len());
buf[..n].copy_from_slice(&self.sector_buf[..n]);
buf[..n].copy_from_slice(&self.batch_buf[..n]);
self.buf_pos = n;
Ok(n)
} else {
+3 -3
View File
@@ -431,10 +431,10 @@ fn parse_resolution(s: &str) -> (u32, u32) {
}
fn parse_sample_rate(s: &str) -> f64 {
if s.contains("96") {
96000.0
} else if s.contains("192") {
if s.contains("192") {
192000.0
} else if s.contains("96") {
96000.0
} else {
48000.0
}
+5 -2
View File
@@ -333,10 +333,12 @@ fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
)?);
ws.phase = WritePhase::Streaming;
// Re-parse buffered data through a fresh demuxer
// Re-parse buffered data through a fresh demuxer, then reset the main
// demuxer so stale PES assembler state from scanning doesn't cause
// duplicate or incomplete packets during streaming.
let pids: Vec<u16> = ws.pid_to_track.iter().map(|(pid, _)| *pid).collect();
let buffered = ws.lookahead.drain();
if !buffered.is_empty() {
let pids: Vec<u16> = ws.pid_to_track.iter().map(|(pid, _)| *pid).collect();
let mut temp = TsDemuxer::new(&pids);
let packets = temp.feed(&buffered);
if let Some(ref mut muxer) = ws.muxer {
@@ -345,6 +347,7 @@ fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> {
}
}
}
ws.demuxer = TsDemuxer::new(&pids);
Ok(())
}
+3
View File
@@ -1,5 +1,8 @@
//! NetworkStream — BD-TS over TCP with embedded metadata.
//!
//! **Security:** Data is transmitted over plain TCP with no encryption.
//! Use only on trusted networks (LAN). TLS support is planned.
//!
//! Write side (sender): connects to a listener, sends FMKV header + BD-TS data.
//! Read side (receiver): listens for a connection, reads FMKV header + BD-TS data.
//!
+1 -1
View File
@@ -78,7 +78,7 @@ impl PsDemuxer {
/// Scan the buffer for complete start-code-delimited units and parse them.
fn extract_packets(&mut self) -> Vec<PsPacket> {
let mut packets = Vec::new();
let mut packets = Vec::with_capacity(4);
let mut pos = 0;
loop {
+1 -1
View File
@@ -122,7 +122,7 @@ impl TsDemuxer {
/// Feed a chunk of BD transport stream data. Handles non-192-byte-aligned input
/// by buffering leftover bytes between calls. Returns completed PES packets.
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
let mut completed = Vec::new();
let mut completed = Vec::with_capacity(4);
// Prepend any remainder from previous call
let work: &[u8];
+12 -2
View File
@@ -55,8 +55,18 @@ impl SgIoTransport {
)
};
if fd < 0 {
return Err(Error::DeviceNotFound {
path: device.display().to_string(),
let err = std::io::Error::last_os_error();
return Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
Error::DevicePermission {
path: format!(
"{}: permission denied (try running as root)",
device.display()
),
}
} else {
Error::DeviceNotFound {
path: device.display().to_string(),
}
});
}
Ok(SgIoTransport { fd })
+10
View File
@@ -88,6 +88,10 @@ pub struct SptiTransport {
}
/// Normalize a device path to Windows \\.\X: format.
///
/// NOTE: A near-identical `normalize_path` exists in `drive::windows`.
/// Both are kept because they live in separate `cfg(windows)` modules that
/// cannot easily share a helper without introducing cross-module coupling.
fn normalize_device_path(path: &str) -> String {
if path.starts_with("\\\\.\\") {
return path.to_string();
@@ -150,6 +154,12 @@ impl ScsiTransport for SptiTransport {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
// Zero the data buffer for reads to prevent returning uninitialized data
// if the driver doesn't fully update DataTransferLength.
if direction == DataDirection::FromDevice {
data.fill(0);
}
let mut sptwb: SptwbDirect = unsafe { std::mem::zeroed() };
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);