libfreemkv 0.31.2: comprehensive spec-grounded test suite (~950 tests)

Test-hardening release, no runtime changes. Adds spec-grounded unit tests
across the silent-corruption surfaces — UDF/MPLS/CLPI/IFO parsing, BD/DVD
title + extent assembly, AACS/CSS key handling, TS/PS demux + codec parsers,
MKV/EBML container output, the mux pipeline, sector prefetch + decrypt
decorator, drive/SCSI sense decoding, label extraction, and core I/O. Each
test is grounded in the format spec or real on-disc behavior and verified to
fail under a targeted source mutation. No behavior changed.
This commit is contained in:
Matthew Jackson
2026-06-07 22:28:29 -07:00
parent 2a55bab3ed
commit 8000bae177
85 changed files with 22998 additions and 1 deletions
+495
View File
@@ -984,4 +984,499 @@ mod tests {
};
assert_eq!(e2.to_string(), format!("E{}: abc", E_NO_DISC_KEY));
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// Every published error code constant must be unique.
/// This pins all code assignments: a new variant that accidentally reuses
/// an existing code will make this test fail.
/// Mutation: changing E_KEYDB_PARSE from 8004 to 8000 (duplicating E_KEYDB_CONNECT) fails here.
#[test]
fn all_error_code_constants_are_unique() {
let mut codes = vec![
E_DEVICE_NOT_FOUND,
E_DEVICE_PERMISSION,
E_DEVICE_NOT_READY,
E_DEVICE_RESET_FAILED,
E_SCSI_INTERFACE_UNAVAILABLE,
E_DEVICE_LOCKED,
E_IOKIT_PLUGIN_FAILED,
E_UNSUPPORTED_DRIVE,
E_PROFILE_PARSE,
E_UNSUPPORTED_PLATFORM,
E_PLATFORM_NOT_IMPLEMENTED,
E_UNLOCK_FAILED,
E_SIGNATURE_MISMATCH,
E_SCSI_ERROR,
E_IO_ERROR,
E_DISC_READ,
E_MPLS_PARSE,
E_CLPI_PARSE,
E_UDF_NOT_FOUND,
E_DISC_TITLE_RANGE,
E_IFO_PARSE,
E_MKV_INVALID,
E_NO_STREAMS,
E_HALTED,
E_MAPFILE_INVALID,
E_UDF_BUFFER_TOO_SMALL,
E_AACS_NO_KEYS,
E_AACS_CERT_SHORT,
E_AACS_AGID_ALLOC,
E_AACS_CERT_REJECTED,
E_AACS_CERT_READ,
E_AACS_CERT_VERIFY,
E_AACS_KEY_READ,
E_AACS_KEY_REJECTED,
E_AACS_KEY_VERIFY,
E_AACS_VID_READ,
E_AACS_VID_MAC,
E_AACS_DATA_KEY,
E_DECRYPT_FAILED,
E_CSS_AUTH_FAILED,
E_AACS_HOST_CERT_REJECTED,
E_AACS_RAW_READ_UNSUPPORTED,
E_AACS_VID_UNAVAILABLE,
E_AACS_MK_UNAVAILABLE,
E_AACS_VUK_NOT_IN_KEYDB,
E_DRIVE_PROFILE_MISSING,
E_VID_CDB_UNAVAILABLE,
E_NO_DISC_KEY,
E_KEYDB_CONNECT,
E_KEYDB_HTTP,
E_KEYDB_INVALID,
E_KEYDB_WRITE,
E_KEYDB_PARSE,
E_KEYDB_LOAD,
E_KEYDB_UNSUPPORTED_SCHEME,
E_KEYDB_TOO_MANY_REDIRECTS,
E_STREAM_READ_ONLY,
E_STREAM_WRITE_ONLY,
E_STREAM_URL_INVALID,
E_STREAM_URL_MISSING_PATH,
E_STREAM_URL_MISSING_PORT,
E_PES_FRAME_TOO_LARGE,
E_PES_INVALID_MAGIC,
E_PES_TRACK_TOO_LARGE,
E_ISO_TOO_LARGE,
E_NO_METADATA,
E_DISC_URL_NOT_DIRECT,
E_HEVC_PARAM_PARSE,
E_MUX_TRACK_RANGE,
E_FMP4_UNIMPLEMENTED,
E_DEMUX_THREAD_PANICKED,
E_PIPELINE_JOIN_TIMEOUT,
E_PIPELINE_CONSUMER_PANICKED,
E_SWEEP_CONSUMER_GONE,
E_PIPELINE_CONSUMER_GONE,
E_DISC_CAPACITY_OVERFLOW,
E_M2TS_PACKET_MALFORMED,
E_EXTENT_NOT_UNIT_ALIGNED,
E_DISC_CAPACITY_MALFORMED,
];
let original_len = codes.len();
codes.sort();
codes.dedup();
assert_eq!(
codes.len(),
original_len,
"duplicate error code constants detected — check error.rs"
);
}
/// Error code ranges match their documented category buckets.
/// E.g. all device codes are 10001999, all AACS codes are 70007999.
/// Mutation: accidentally shifting a constant out of its range (e.g. E_DEVICE_NOT_FOUND = 2000)
/// breaks CLI range-based dispatch and logging.
#[test]
fn error_code_range_buckets_are_correct() {
// Device (1xxx)
assert!((1000..2000).contains(&E_DEVICE_NOT_FOUND));
assert!((1000..2000).contains(&E_DEVICE_PERMISSION));
assert!((1000..2000).contains(&E_SCSI_INTERFACE_UNAVAILABLE));
// Profile (2xxx)
assert!((2000..3000).contains(&E_UNSUPPORTED_DRIVE));
assert!((2000..3000).contains(&E_PROFILE_PARSE));
// Unlock (3xxx)
assert!((3000..4000).contains(&E_UNLOCK_FAILED));
assert!((3000..4000).contains(&E_SIGNATURE_MISMATCH));
// SCSI (4xxx)
assert!((4000..5000).contains(&E_SCSI_ERROR));
// I/O (5xxx)
assert!((5000..6000).contains(&E_IO_ERROR));
// Disc format (6xxx)
assert!((6000..7000).contains(&E_DISC_READ));
assert!((6000..7000).contains(&E_HALTED));
assert!((6000..7000).contains(&E_MAPFILE_INVALID));
// AACS (7xxx)
assert!((7000..8000).contains(&E_AACS_NO_KEYS));
assert!((7000..8000).contains(&E_NO_DISC_KEY));
// Keydb (8xxx)
assert!((8000..9000).contains(&E_KEYDB_CONNECT));
assert!((8000..9000).contains(&E_KEYDB_TOO_MANY_REDIRECTS));
// Stream/mux (9xxx)
assert!((9000..10000).contains(&E_STREAM_READ_ONLY));
assert!((9000..10000).contains(&E_DISC_CAPACITY_MALFORMED));
}
/// Error.code() matches its associated constant for every new 9xxx variant.
/// Mutation: swapping two adjacent code() arms (e.g. SweepConsumerGone ↔
/// PipelineConsumerGone) makes the wrong code appear in logs.
#[test]
fn error_code_matches_constant_for_stream_variants() {
use std::io::ErrorKind;
let cases: &[(Error, u16)] = &[
(Error::StreamReadOnly, E_STREAM_READ_ONLY),
(Error::StreamWriteOnly, E_STREAM_WRITE_ONLY),
(Error::PesInvalidMagic, E_PES_INVALID_MAGIC),
(Error::NoMetadata, E_NO_METADATA),
(Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
(Error::HevcParamParse, E_HEVC_PARAM_PARSE),
(Error::Fmp4Unimplemented, E_FMP4_UNIMPLEMENTED),
(Error::DemuxThreadPanicked, E_DEMUX_THREAD_PANICKED),
(
Error::PipelineConsumerPanicked,
E_PIPELINE_CONSUMER_PANICKED,
),
(Error::SweepConsumerGone, E_SWEEP_CONSUMER_GONE),
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
];
for (e, expected_code) in cases {
assert_eq!(
e.code(),
*expected_code,
"{:?}.code() must equal {} (const)",
e,
expected_code
);
}
// io::ErrorKind mapping spot-check for 9xxx variants.
let to_kind = |e: Error| -> ErrorKind {
let io: std::io::Error = e.into();
io.kind()
};
assert_eq!(to_kind(Error::StreamReadOnly), ErrorKind::Unsupported);
assert_eq!(to_kind(Error::HevcParamParse), ErrorKind::InvalidData);
assert_eq!(to_kind(Error::Fmp4Unimplemented), ErrorKind::Unsupported);
assert_eq!(to_kind(Error::PipelineJoinTimeout), ErrorKind::TimedOut);
assert_eq!(
to_kind(Error::ExtentNotUnitAligned),
ErrorKind::InvalidInput
);
}
/// Error.code() for AACS variants matches their constants.
/// Mutation: swapping E_AACS_CERT_READ and E_AACS_CERT_VERIFY codes
/// makes the wrong diagnostic appear in the UI.
#[test]
fn error_code_matches_constant_for_aacs_variants() {
let aacs_cases: &[(Error, u16)] = &[
(Error::AacsNoKeys, E_AACS_NO_KEYS),
(Error::AacsCertShort, E_AACS_CERT_SHORT),
(Error::AacsAgidAlloc, E_AACS_AGID_ALLOC),
(Error::AacsCertRejected, E_AACS_CERT_REJECTED),
(Error::AacsCertRead, E_AACS_CERT_READ),
(Error::AacsCertVerify, E_AACS_CERT_VERIFY),
(Error::AacsKeyRead, E_AACS_KEY_READ),
(Error::AacsKeyRejected, E_AACS_KEY_REJECTED),
(Error::AacsKeyVerify, E_AACS_KEY_VERIFY),
(Error::AacsVidRead, E_AACS_VID_READ),
(Error::AacsVidMac, E_AACS_VID_MAC),
(Error::AacsDataKey, E_AACS_DATA_KEY),
(Error::DecryptFailed, E_DECRYPT_FAILED),
(Error::CssAuthFailed, E_CSS_AUTH_FAILED),
(Error::AacsHostCertRejected, E_AACS_HOST_CERT_REJECTED),
(Error::AacsRawReadUnsupported, E_AACS_RAW_READ_UNSUPPORTED),
(Error::AacsVidUnavailable, E_AACS_VID_UNAVAILABLE),
(Error::AacsMkUnavailable, E_AACS_MK_UNAVAILABLE),
(Error::AacsVukNotInKeydb, E_AACS_VUK_NOT_IN_KEYDB),
(Error::DriveProfileMissing, E_DRIVE_PROFILE_MISSING),
(Error::VidCdbUnavailable, E_VID_CDB_UNAVAILABLE),
];
for (e, expected_code) in aacs_cases {
assert_eq!(
e.code(),
*expected_code,
"{:?}.code() must be {}",
e,
expected_code
);
}
}
/// is_scsi_transport_failure returns true only for SCSI_STATUS_TRANSPORT_FAILURE (0xFF).
/// Spec: comment on SCSI_STATUS_TRANSPORT_FAILURE says "synthesised sentinel: the
/// transport never delivered a SCSI status byte".
/// Mutation: testing against 0x02 (CHECK CONDITION) would wrongly mark CHECK
/// CONDITION replies as transport failures.
#[test]
fn is_scsi_transport_failure_only_for_0xff() {
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
// True: transport failure sentinel.
let tf = Error::ScsiError {
opcode: 0x28,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(tf.is_scsi_transport_failure());
// False: CHECK CONDITION is a real SCSI reply, not a transport failure.
let cc = Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x03,
asc: 0x11,
ascq: 0x00,
}),
};
assert!(!cc.is_scsi_transport_failure());
// False for non-SCSI errors.
assert!(!Error::Halted.is_scsi_transport_failure());
}
/// is_marginal_read returns true for MEDIUM ERROR (3), NOT READY (2),
/// ABORTED COMMAND (B), RECOVERED ERROR (1), NO SENSE (0).
/// Spec: comment on is_marginal_read lists these five sense keys.
/// Mutation: removing NOT_READY from the marginal set means BU40N "bad sector"
/// responses are treated as fatal instead of retriable.
#[test]
fn is_marginal_read_sense_key_coverage() {
use crate::scsi::ScsiSense;
let marginal_keys = [
0x00, // NO SENSE
0x01, // RECOVERED ERROR
0x02, // NOT READY — dominant BU40N bad-sector sense key
0x03, // MEDIUM ERROR — canonical bad sector
0x0B, // ABORTED COMMAND
];
for sk in marginal_keys {
let e = Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(ScsiSense {
sense_key: sk,
asc: 0x11,
ascq: 0x00,
}),
};
assert!(
e.is_marginal_read(),
"sense_key=0x{:02x} must be marginal",
sk
);
}
// Non-marginal keys: HARDWARE ERROR (4), ILLEGAL REQUEST (5),
// UNIT ATTENTION (6), DATA PROTECT (7), BLANK CHECK (8).
let non_marginal_keys = [0x04, 0x05, 0x06, 0x07, 0x08];
for sk in non_marginal_keys {
let e = Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(ScsiSense {
sense_key: sk,
asc: 0x00,
ascq: 0x00,
}),
};
assert!(
!e.is_marginal_read(),
"sense_key=0x{:02x} must NOT be marginal",
sk
);
}
}
/// is_bridge_degradation returns true for a status byte that is not GOOD,
/// CHECK CONDITION, or TRANSPORT_FAILURE.
/// Spec: comment says "bridge firmware returns non-standard status bytes
/// (e.g. 0x04, 0x05) with empty sense data."
/// Mutation: checking only for 0x04 misses 0x05 and other degradation bytes.
#[test]
fn is_bridge_degradation_detects_non_standard_status() {
use crate::scsi::{
SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_GOOD, SCSI_STATUS_TRANSPORT_FAILURE,
};
// 0x04 and 0x05 are non-standard bridge degradation codes.
for bad_status in [0x04u8, 0x05, 0x08, 0x10] {
let e = Error::ScsiError {
opcode: 0x28,
status: bad_status,
sense: None,
};
assert!(
e.is_bridge_degradation(),
"status=0x{:02x} must be bridge degradation",
bad_status
);
}
// Standard codes must NOT be classified as bridge degradation.
assert!(
!Error::ScsiError {
opcode: 0x28,
status: SCSI_STATUS_GOOD,
sense: None
}
.is_bridge_degradation()
);
assert!(
!Error::ScsiError {
opcode: 0x28,
status: SCSI_STATUS_CHECK_CONDITION,
sense: None
}
.is_bridge_degradation()
);
assert!(
!Error::ScsiError {
opcode: 0x28,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None
}
.is_bridge_degradation()
);
}
/// scsi_sense returns Some for ScsiError with sense and DiscRead with sense.
/// Mutation: only checking ScsiError misses DiscRead sense data.
#[test]
fn scsi_sense_from_disc_read() {
use crate::scsi::ScsiSense;
let sense = ScsiSense {
sense_key: 0x02,
asc: 0x04,
ascq: 0x3e,
};
let disc_read = Error::DiscRead {
sector: 12345,
status: Some(0x02),
sense: Some(sense),
};
let got = disc_read.scsi_sense().unwrap();
assert_eq!(got.sense_key, 0x02);
assert_eq!(got.asc, 0x04);
assert_eq!(got.ascq, 0x3e);
// Non-SCSI errors return None.
assert!(Error::Halted.scsi_sense().is_none());
assert!(Error::NoMetadata.scsi_sense().is_none());
}
/// Display for SignatureMismatch includes both expected and got bytes in hex.
/// Mutation: printing only `expected` without `got` makes the mismatch undiscoverable.
#[test]
fn signature_mismatch_display_includes_both_sides() {
let e = Error::SignatureMismatch {
expected: [0xAA, 0xBB, 0xCC, 0xDD],
got: [0x11, 0x22, 0x33, 0x44],
};
let s = e.to_string();
// Must include the E-code prefix.
assert!(
s.starts_with(&format!("E{}", E_SIGNATURE_MISMATCH)),
"must start with code: {s}"
);
// Must include the expected bytes.
assert!(s.contains("aabbccdd"), "must contain expected bytes: {s}");
// Must include the got bytes.
assert!(s.contains("11223344"), "must contain got bytes: {s}");
// Must use '!=' as the separator between expected and got.
assert!(s.contains("!="), "must use '!=' separator: {s}");
}
/// DiscTitleRange display format is "E6005: index/count".
/// Mutation: swapping index and count in the format string makes logs misleading.
#[test]
fn disc_title_range_display_is_index_slash_count() {
let e = Error::DiscTitleRange {
index: 3,
count: 10,
};
let expected = format!("E{}: 3/10", E_DISC_TITLE_RANGE);
assert_eq!(e.to_string(), expected);
}
/// Keydb error variants display correctly with their structured data.
/// Mutation: using a generic "E{code}" fallback drops the host/path data from logs.
#[test]
fn keydb_errors_include_structured_data_in_display() {
let e_connect = Error::KeydbConnect {
host: "mirror.example".into(),
};
assert!(
e_connect.to_string().contains("mirror.example"),
"KeydbConnect display must include host"
);
let e_http = Error::KeydbHttp { status: 403 };
assert!(
e_http.to_string().contains("403"),
"KeydbHttp display must include status code"
);
let e_write = Error::KeydbWrite {
path: "/root/.config/freemkv/keydb.cfg".into(),
};
assert!(
e_write.to_string().contains("/root"),
"KeydbWrite display must include path"
);
let e_load = Error::KeydbLoad {
path: "<no keydb in search paths>".into(),
};
assert!(
e_load.to_string().contains("<no keydb in search paths>"),
"KeydbLoad display must include the sentinel path"
);
let e_scheme = Error::KeydbUnsupportedScheme {
scheme: "ftp".into(),
};
assert!(
e_scheme.to_string().contains("ftp"),
"KeydbUnsupportedScheme display must include scheme"
);
}
/// MuxTrackRange display format is "E9011: track/tracks".
/// Mutation: formatting as "track/count" or "tracks/track" is wrong.
#[test]
fn mux_track_range_display_is_track_slash_tracks() {
let e = Error::MuxTrackRange {
track: 5,
tracks: 3,
};
let expected = format!("E{}: 5/3", E_MUX_TRACK_RANGE);
assert_eq!(e.to_string(), expected);
}
/// All keydb error codes are 8xxx.
/// Mutation: creating E_KEYDB_PARSE = 9004 (colliding with E_KEYDB_LOAD range)
/// breaks the CLI's range-based keydb error dispatch.
#[test]
fn keydb_error_codes_all_in_8xxx_range() {
let keydb_codes = [
E_KEYDB_CONNECT,
E_KEYDB_HTTP,
E_KEYDB_INVALID,
E_KEYDB_WRITE,
E_KEYDB_PARSE,
E_KEYDB_LOAD,
E_KEYDB_UNSUPPORTED_SCHEME,
E_KEYDB_TOO_MANY_REDIRECTS,
];
for code in keydb_codes {
assert!(
(8000..9000).contains(&code),
"keydb code {} must be in 8xxx range",
code
);
}
}
}