//! Error types for libfreemkv. //! //! Every error is a code with structured data. No English text. //! Applications map codes to localized messages. //! //! # Error Code Ranges //! //! | Range | Category | //! |-------|----------| //! | E1xxx | Device errors | //! | E2xxx | Profile errors | //! | E3xxx | Unlock errors | //! | E4xxx | SCSI errors | //! | E5xxx | I/O errors | //! | E6xxx | Disc format errors | //! | E7xxx | AACS errors | //! | E8xxx | Keydb errors | //! | E9xxx | Stream/mux errors | // ── Error codes ───────────────────────────────────────────────────────────── // Device (1xxx) pub const E_DEVICE_NOT_FOUND: u16 = 1000; pub const E_DEVICE_PERMISSION: u16 = 1001; pub const E_DEVICE_NOT_READY: u16 = 1002; pub const E_DEVICE_RESET_FAILED: u16 = 1003; pub const E_SCSI_INTERFACE_UNAVAILABLE: u16 = 1004; pub const E_DEVICE_LOCKED: u16 = 1005; pub const E_IOKIT_PLUGIN_FAILED: u16 = 1006; // Profile (2xxx) pub const E_UNSUPPORTED_DRIVE: u16 = 2000; // 2001: burned/retired — do not reuse. pub const E_PROFILE_PARSE: u16 = 2002; pub const E_UNSUPPORTED_PLATFORM: u16 = 2003; pub const E_PLATFORM_NOT_IMPLEMENTED: u16 = 2004; // Unlock (3xxx) pub const E_UNLOCK_FAILED: u16 = 3000; pub const E_SIGNATURE_MISMATCH: u16 = 3001; // SCSI (4xxx) pub const E_SCSI_ERROR: u16 = 4000; pub const E_INVALID_CDB_LENGTH: u16 = 4001; // I/O (5xxx) pub const E_IO_ERROR: u16 = 5000; // Disc format (6xxx) pub const E_DISC_READ: u16 = 6000; pub const E_MPLS_PARSE: u16 = 6001; pub const E_CLPI_PARSE: u16 = 6002; pub const E_UDF_NOT_FOUND: u16 = 6003; // 6004: burned/retired — do not reuse. pub const E_DISC_TITLE_RANGE: u16 = 6005; // 6006: burned/retired — do not reuse. pub const E_IFO_PARSE: u16 = 6007; pub const E_MKV_INVALID: u16 = 6008; pub const E_NO_STREAMS: u16 = 6009; pub const E_HALTED: u16 = 6010; pub const E_MAPFILE_INVALID: u16 = 6011; pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012; // AACS (7xxx) pub const E_AACS_NO_KEYS: u16 = 7000; pub const E_AACS_CERT_SHORT: u16 = 7001; pub const E_AACS_AGID_ALLOC: u16 = 7002; pub const E_AACS_CERT_REJECTED: u16 = 7003; pub const E_AACS_CERT_READ: u16 = 7004; pub const E_AACS_CERT_VERIFY: u16 = 7005; pub const E_AACS_KEY_READ: u16 = 7006; pub const E_AACS_KEY_REJECTED: u16 = 7007; pub const E_AACS_KEY_VERIFY: u16 = 7008; pub const E_AACS_VID_READ: u16 = 7009; pub const E_AACS_VID_MAC: u16 = 7010; pub const E_AACS_DATA_KEY: u16 = 7011; // 7012: burned/retired — do not reuse. pub const E_DECRYPT_FAILED: u16 = 7013; pub const E_CSS_AUTH_FAILED: u16 = 7014; pub const E_AACS_HOST_CERT_REJECTED: u16 = 7015; pub const E_AACS_RAW_READ_UNSUPPORTED: u16 = 7016; pub const E_AACS_VID_UNAVAILABLE: u16 = 7017; pub const E_AACS_MK_UNAVAILABLE: u16 = 7018; pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019; pub const E_DRIVE_PROFILE_MISSING: u16 = 7020; pub const E_VID_CDB_UNAVAILABLE: u16 = 7021; pub const E_NO_DISC_KEY: u16 = 7022; pub const E_CSS_KEY_MISSING: u16 = 7023; pub const E_AACS_NO_HOST_CERT: u16 = 7024; pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025; pub const E_FMTS_KEY_MISSING: u16 = 7026; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; pub const E_KEYDB_HTTP: u16 = 8001; pub const E_KEYDB_INVALID: u16 = 8002; pub const E_KEYDB_WRITE: u16 = 8003; pub const E_KEYDB_PARSE: u16 = 8004; pub const E_KEYDB_LOAD: u16 = 8005; pub const E_KEYDB_UNSUPPORTED_SCHEME: u16 = 8006; pub const E_KEYDB_TOO_MANY_REDIRECTS: u16 = 8007; // Stream/mux (9xxx) pub const E_STREAM_READ_ONLY: u16 = 9000; pub const E_STREAM_WRITE_ONLY: u16 = 9001; pub const E_STREAM_URL_INVALID: u16 = 9002; pub const E_STREAM_URL_MISSING_PATH: u16 = 9003; pub const E_STREAM_URL_MISSING_PORT: u16 = 9004; pub const E_PES_FRAME_TOO_LARGE: u16 = 9005; pub const E_PES_INVALID_MAGIC: u16 = 9006; pub const E_ISO_TOO_LARGE: u16 = 9007; pub const E_NO_METADATA: u16 = 9008; pub const E_DISC_URL_NOT_DIRECT: u16 = 9009; /// `--raw` given with a `dir://` destination (raw + decrypted-tree is /// a contradiction; raw bytes go to `iso://`). pub const E_DIR_RAW_REJECTED: u16 = 9019; pub const E_HEVC_PARAM_PARSE: u16 = 9010; pub const E_MUX_TRACK_RANGE: u16 = 9011; pub const E_FMP4_UNIMPLEMENTED: u16 = 9012; pub const E_DEMUX_THREAD_PANICKED: u16 = 9013; pub const E_PIPELINE_JOIN_TIMEOUT: u16 = 9014; pub const E_PIPELINE_CONSUMER_PANICKED: u16 = 9015; pub const E_SWEEP_CONSUMER_GONE: u16 = 9016; pub const E_PES_TRACK_TOO_LARGE: u16 = 9017; pub const E_PIPELINE_CONSUMER_GONE: u16 = 9018; pub const E_DISC_CAPACITY_OVERFLOW: u16 = 9020; /// `--multipass` given with a `dir://` destination (`dir://` is 1-shot; /// recovery is the `iso://` path's job). pub const E_DIR_MULTIPASS_REJECTED: u16 = 9024; /// A non-disc (byte-stream) source was routed into `dir://`, which needs a /// filesystem (only `disc://` / `iso://` qualify). pub const E_DIR_SOURCE_UNSUPPORTED: u16 = 9025; /// `dir://` target directory is non-empty and `--force` was not given. pub const E_DIR_NOT_EMPTY: u16 = 9026; /// `dir://` target filesystem free space is below the sum of file extents. pub const E_DIR_INSUFFICIENT_SPACE: u16 = 9027; /// Two distinct disc paths sanitize to the same host path (would silently /// overwrite — surfaced as a hard error instead). pub const E_DIR_NAME_COLLISION: u16 = 9028; /// A `dir://` create_dir_all / file write / rename failed. pub const E_DIR_WRITE_FAILED: u16 = 9029; pub const E_M2TS_PACKET_MALFORMED: u16 = 9021; /// A `network://` output target resolved to no address that is safe to /// connect to (every resolved IP was loopback / private / link-local / /// multicast / unspecified). Closes the DNS-rebinding SSRF window. pub const E_NETWORK_ADDR_BLOCKED: u16 = 9022; /// A muxer's `finish()` was called after zero frames were emitted — the /// output would be a header-only container with no media. Surfaced so a /// zero-frame mux (undecryptable input, fully-unreadable title, every /// frame dropped before the first keyframe) cannot report success. pub const E_MUX_EMPTY: u16 = 9023; pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030; /// READ CAPACITY returned a short or overflowing transfer. pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047; // ── Error enum ────────────────────────────────────────────────────────────── /// Structured error with numeric code and context data. No English text. /// /// Marked `#[non_exhaustive]`: downstream crates must not match it /// exhaustively, so new variants can be added without a semver break. #[derive(Debug)] #[non_exhaustive] pub enum Error { // Device (1xxx) DeviceNotFound { path: String, }, DevicePermission { path: String, }, DeviceNotReady { path: String, }, DeviceResetFailed { path: String, }, /// Platform-specific SCSI interface couldn't be obtained from the OS /// (macOS: `SCSITaskDeviceInterface` unavailable). The `path` field /// carries the device path; no English commentary on the failure mode. ScsiInterfaceUnavailable { path: String, }, /// Device is held by another process / kernel state. `kr` is the /// platform return code (macOS IOReturn, Linux errno-equivalent). DeviceLocked { path: String, kr: u32, }, /// macOS IOKit plugin couldn't be created for this device. `kr` is /// the IOReturn code from `IOCreatePlugInInterfaceForService`. IoKitPluginFailed { path: String, kr: u32, }, // Profile (2xxx) UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String, }, ProfileParse, /// SCSI transport was requested on an OS without a backend /// implementation. `target` is the `std::env::consts::OS` value. UnsupportedPlatform { target: String, }, /// Drive matched a known platform that we haven't implemented yet /// (e.g. Renesas firmware). `platform` is a stable identifier. PlatformNotImplemented { platform: String, }, // Unlock (3xxx) UnlockFailed, SignatureMismatch { expected: [u8; 4], got: [u8; 4], }, // SCSI (4xxx) /// SCSI command failed. /// /// `opcode` is the failing CDB byte 0. `status` is the raw SCSI /// status byte: `0x02` = CHECK CONDITION (drive replied with sense /// data), `0xFF` = libfreemkv-synthesised sentinel meaning "no SCSI /// status delivered" (kernel timeout, USB bridge wedge, IOKit /// service failure). `sense` carries the drive's SPC-4 sense triple /// when the drive replied; `None` for transport-layer failures. /// /// Recommended dispatch (callers shouldn't pattern-match raw /// fields): /// - [`Error::is_scsi_transport_failure`] — bail; bridge/transport wedge /// - [`Error::is_marginal_read`] — drive said this read was marginal; smaller block may recover /// - [`Error::scsi_sense`] — borrow the sense triple for finer routing ([`ScsiSense::is_medium_error`] etc.) ScsiError { opcode: u8, status: u8, sense: Option, }, /// CDB supplied to the transport exceeded the maximum supported length. /// `len` is the supplied CDB length; `max` is the transport's limit. InvalidCdbLength { len: usize, max: usize, }, // I/O (5xxx) IoError { source: std::io::Error, }, // Disc format (6xxx) DiscRead { sector: u64, status: Option, sense: Option, }, /// Drive was halted by caller. Halted, MplsParse, ClpiParse, UdfNotFound { path: String, }, /// A `SectorSource` caller passed a destination buffer smaller than one /// 2048-byte sector. A contract violation on the public reader API — /// returned instead of panicking on the slice. UdfBufferTooSmall, DiscTitleRange { index: usize, count: usize, }, IfoParse, MkvInvalid, NoStreams, /// ddrescue mapfile parse failed. `kind` is a stable, language-neutral /// identifier (e.g. `"status_char"`, `"hex"`); not a translatable /// English message. MapfileInvalid { kind: &'static str, }, // AACS (7xxx) AacsNoKeys, AacsCertShort, AacsAgidAlloc, AacsCertRejected, AacsCertRead, AacsCertVerify, AacsKeyRead, AacsKeyRejected, AacsKeyVerify, AacsVidRead, AacsVidMac, AacsDataKey, DecryptFailed, CssAuthFailed, /// Host certificate rejected by the drive's revocation list (HRL hit). /// All available host certs failed mutual auth on this drive. AacsHostCertRejected, /// Drive cannot be put into raw-read mode and standard AACS cert /// auth failed. No path to decryption remains. AacsRawReadUnsupported, /// Volume ID could not be retrieved from the drive (neither via cert /// auth nor via the alternate VID read path). Downstream of step 1 /// of the AACS chain. AacsVidUnavailable, /// No available path produced a Media Key (no MK+VID in keydb, no /// PK match, no DK derivation). AacsMkUnavailable, /// Disc-hash lookup in the keydb missed and no other path is /// available (typically because VID is missing). AacsVukNotInKeydb, /// Drive identity did not match any bundled profile; per-drive CDB /// templates aren't available so the OEM VID retrieval path can't /// run. DriveProfileMissing, /// Drive's profile is present but doesn't carry a VID-retrieval CDB /// template (older profile blob, or a drive class without an OEM /// VID path). VidCdbUnavailable, /// The disc is AACS-encrypted and decryption was requested, but key /// resolution produced no usable key for it — so muxing would emit /// undecryptable garbage. Distinct from [`Error::KeydbLoad`] (no keydb /// file at all): a keydb may be present but lack an entry for this disc. /// `disc_hash` is the 40-hex SHA1 of `Unit_Key_RO.inf` (no `0x` prefix) /// so the application can name the disc; empty if the hash wasn't /// captured at scan. NoDiscKey { disc_hash: String, }, /// The disc is CSS-encrypted and decryption was requested, but the /// known-plaintext crack resolved no usable title key for the chosen /// title (e.g. a multi-VTS DVD where the title's VTS could not be /// re-cracked). Muxing would emit scrambled ciphertext, so the caller /// fails fast instead. CSS analogue of [`Error::NoDiscKey`]. CssKeyMissing, /// The live-drive AACS cert-auth handshake (the OEM/AACS baseline route) /// could not run because NO host certificate was available from any key /// source. Host certs are keysource-served, never compiled in, so without /// a keysource that supplies one the OEM route fails gracefully here — this /// is the intended outcome, not a panic. Resolution still proceeds with a /// zero Volume ID and relies on the path-1 disc-hash → VUK lookup, so the /// error is dropped when that lookup hits. `path` carries the sentinel /// `` (mirroring [`Error::KeydbLoad`]'s sentinel) so a CLI can /// render "No Host Certs Found." AacsNoHostCert { path: String, }, /// A bus-encrypted disc (AACS 2.0 / UHD, Content Certificate bus-encryption /// bit set) was scanned on a live drive, the Volume ID was obtained, but no /// `read_data_key` (bus key) was produced — so the on-disc bytes are still /// bus-encrypted and would decrypt to garbage. The bus key is derivable ONLY /// from the AACS host-certificate cert-auth handshake; a VID-only OEM unlock /// path (which returns no bus key) is insufficient for such a disc. Surfaced /// instead of silently producing a corrupt rip. NOT raised for AACS 1.0 BD /// (no bus encryption, `read_data_key` legitimately absent) nor for /// file-backed (ISO) scans, where bus encryption was already removed at read /// time and no handshake runs. AacsBusKeyUnavailable, /// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment /// (variant) key is available to open them, and `BYPASS_FMTS_KEY` is `false` /// (strict mode). Raised UPFRONT — before the mux — exactly like a missing /// unit key, so a 2.1 disc that would rip with holes is refused rather than /// silently producing a forensic-holed output. When `BYPASS_FMTS_KEY` is /// `true` (the default today) this is never raised: the bulk decodes with the /// unit key and the forensic segments are skipped as expected loss. FmtsKeyMissing, // Keydb (8xxx) KeydbConnect { host: String, }, KeydbHttp { status: u16, }, KeydbInvalid, KeydbWrite { path: String, }, KeydbParse, KeydbLoad { path: String, }, /// A redirect (or the configured URL) targets a scheme this /// dependency-light HTTP client cannot fetch (e.g. `https://`). /// Carries the offending scheme for diagnostics. KeydbUnsupportedScheme { scheme: String, }, /// The redirect chain exceeded the follow limit. KeydbTooManyRedirects, // Stream/mux (9xxx) StreamReadOnly, StreamWriteOnly, StreamUrlInvalid { url: String, }, StreamUrlMissingPath { scheme: String, }, StreamUrlMissingPort { addr: String, }, /// A `network://` output host resolved to no connectable address — /// every resolved IP was loopback / private / link-local / multicast / /// unspecified. Carries the offending `host:port`. Re-checked at /// connect time to close the DNS-rebinding TOCTOU. NetworkAddrBlocked { addr: String, }, /// A muxer's `finish()` was reached after zero frames were written, so /// the output would be a header-only container with no media. Surfaced /// (instead of writing a valid-but-empty file and reporting success) so /// a zero-frame mux — undecryptable input, a fully-unreadable title, or /// every frame dropped before the first keyframe — fails loudly. The /// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard. MuxEmpty, PesFrameTooLarge { size: usize, }, PesInvalidMagic, /// PES frame track index exceeds the 1-byte on-wire field (> 255). /// Carries the offending index. Distinct from [`Error::PesInvalidMagic`], /// which signals corrupt input on the read side. PesTrackTooLarge { track: usize, }, IsoTooLarge { path: String, }, NoMetadata, /// `disc://` URLs aren't openable through `input()` — callers must use /// `Drive::open() + Disc::scan() + DiscStream::new()` directly. This /// is a structural API constraint, not a parse failure. DiscUrlNotDirect, /// A non-empty `HEVCDecoderConfigurationRecord` (hvcC) was supplied to /// a muxer but failed to parse into any VPS/SPS/PPS NAL — emitting the /// stream without parameter sets would yield an undecodable result. HevcParamParse, /// A muxer `write_frame` / `set_codec_private` was given a track index /// beyond the configured PID/track count. MuxTrackRange { track: usize, tracks: usize, }, /// The fragmented-MP4 sink cannot emit media — `moof`/`mdat` framing is /// not implemented. Surfaced instead of silently discarding samples. Fmp4Unimplemented, /// A worker thread in the threaded mux pipeline terminated without /// sending its terminal sentinel — i.e. it panicked or was dropped /// mid-stream. Surfaced so a parser/demux panic is never silently /// reported to the caller as a clean end-of-stream (which would /// truncate output without any error). DemuxThreadPanicked, /// A pipeline `join()` exceeded its deadline while waiting for the /// consumer thread to drain. The consumer is intentionally leaked; /// the caller should fall back to a degraded path. PipelineJoinTimeout, /// The pipeline consumer thread panicked. The original panic /// payload is not preserved (no English text in the library); it is /// logged at the panic site instead. PipelineConsumerPanicked, /// A pipeline producer's `send` failed because the consumer thread /// has already terminated (the receiver end is gone). SweepConsumerGone, /// A producer thread tried to hand work to its pipeline consumer /// (sweep / patch sink) but the consumer thread had already /// terminated (panicked or dropped the receiver). The producer /// surfaces this so the outer pass can abort cleanly instead of /// blocking on a dead channel. PipelineConsumerGone, /// READ CAPACITY(10) reported a last-LBA of `0xFFFFFFFF` — the SPC /// sentinel meaning "capacity exceeds 32-bit addressing". Adding 1 to /// derive the sector count would overflow `u32`. Reachable from /// disc-reported bytes and synthetic [`crate::sector::SectorSource`] /// fixtures. DiscCapacityOverflow, /// An extent fed to the prefetch producer has a `sector_count` /// whose trailing 1-2 sectors cannot form a complete AACS aligned /// unit (3 sectors / 6144 bytes). Emitting that tail as a /// standalone batch would hand the decrypt step a sub-unit chunk /// it silently leaves encrypted. The producer surfaces this rather /// than emit still-encrypted bytes. ExtentNotUnitAligned, /// An MPEG-TS packet under construction violated the 188-byte fixed /// size (over-long adaptation field, overflowing payload, or a /// short/mis-assembled packet). Indicates a muxer invariant break, /// not untrusted input — surfaced instead of writing a corrupt /// transport stream. M2tsPacketMalformed, /// READ CAPACITY transferred fewer than 4 bytes, or the decoded /// last-LBA + 1 overflowed `u32`. Either case means the capacity /// response is unusable; no English commentary. DiscCapacityMalformed, /// `--raw` was given with a `dir://` destination. An encrypted file /// tree is useless; raw bytes belong in `iso://`. DirRawRejected, /// `--multipass` was given with a `dir://` destination. `dir://` is /// 1-shot; recovery is the `iso://` multipass path's job. DirMultipassRejected, /// A non-disc (byte-stream) source was routed into `dir://`, which /// requires a filesystem (only `disc://` / `iso://` qualify). DirSourceUnsupported, /// The `dir://` target directory is non-empty and `--force` was not /// given. Mixing two discs' trees is refused by default. DirNotEmpty, /// The `dir://` target filesystem's free space is below the sum of /// the file extents to extract. Carries required / available bytes. DirInsufficientSpace { required: u64, available: u64, }, /// Two distinct disc paths sanitize to the same host path. Surfaced /// as a hard error rather than a silent overwrite. Carries the /// colliding host component. DirNameCollision { host: String, }, /// A `dir://` create_dir_all / file write / rename failed. Carries /// the underlying errno when present. DirWriteFailed { errno: Option, }, } impl Error { pub fn code(&self) -> u16 { match self { Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND, Error::DevicePermission { .. } => E_DEVICE_PERMISSION, Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY, Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED, Error::ScsiInterfaceUnavailable { .. } => E_SCSI_INTERFACE_UNAVAILABLE, Error::DeviceLocked { .. } => E_DEVICE_LOCKED, Error::IoKitPluginFailed { .. } => E_IOKIT_PLUGIN_FAILED, Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE, Error::ProfileParse => E_PROFILE_PARSE, Error::UnsupportedPlatform { .. } => E_UNSUPPORTED_PLATFORM, Error::PlatformNotImplemented { .. } => E_PLATFORM_NOT_IMPLEMENTED, Error::UnlockFailed => E_UNLOCK_FAILED, Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH, Error::ScsiError { .. } => E_SCSI_ERROR, Error::InvalidCdbLength { .. } => E_INVALID_CDB_LENGTH, Error::IoError { .. } => E_IO_ERROR, Error::DiscRead { .. } => E_DISC_READ, Error::Halted => E_HALTED, Error::MplsParse => E_MPLS_PARSE, Error::ClpiParse => E_CLPI_PARSE, Error::UdfNotFound { .. } => E_UDF_NOT_FOUND, Error::UdfBufferTooSmall => E_UDF_BUFFER_TOO_SMALL, Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE, Error::IfoParse => E_IFO_PARSE, Error::MkvInvalid => E_MKV_INVALID, Error::NoStreams => E_NO_STREAMS, Error::MapfileInvalid { .. } => E_MAPFILE_INVALID, 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, Error::NoDiscKey { .. } => E_NO_DISC_KEY, Error::CssKeyMissing => E_CSS_KEY_MISSING, Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT, Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE, Error::FmtsKeyMissing => E_FMTS_KEY_MISSING, Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbHttp { .. } => E_KEYDB_HTTP, Error::KeydbInvalid => E_KEYDB_INVALID, Error::KeydbWrite { .. } => E_KEYDB_WRITE, Error::KeydbParse => E_KEYDB_PARSE, Error::KeydbLoad { .. } => E_KEYDB_LOAD, Error::KeydbUnsupportedScheme { .. } => E_KEYDB_UNSUPPORTED_SCHEME, Error::KeydbTooManyRedirects => E_KEYDB_TOO_MANY_REDIRECTS, Error::StreamReadOnly => E_STREAM_READ_ONLY, Error::StreamWriteOnly => E_STREAM_WRITE_ONLY, Error::StreamUrlInvalid { .. } => E_STREAM_URL_INVALID, Error::StreamUrlMissingPath { .. } => E_STREAM_URL_MISSING_PATH, Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT, Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED, Error::MuxEmpty => E_MUX_EMPTY, Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE, Error::PesInvalidMagic => E_PES_INVALID_MAGIC, Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE, Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE, Error::NoMetadata => E_NO_METADATA, Error::DiscUrlNotDirect => E_DISC_URL_NOT_DIRECT, Error::HevcParamParse => E_HEVC_PARAM_PARSE, Error::MuxTrackRange { .. } => E_MUX_TRACK_RANGE, Error::Fmp4Unimplemented => E_FMP4_UNIMPLEMENTED, Error::DemuxThreadPanicked => E_DEMUX_THREAD_PANICKED, Error::PipelineJoinTimeout => E_PIPELINE_JOIN_TIMEOUT, 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::ExtentNotUnitAligned => E_EXTENT_NOT_UNIT_ALIGNED, Error::M2tsPacketMalformed => E_M2TS_PACKET_MALFORMED, Error::DiscCapacityMalformed => E_DISC_CAPACITY_MALFORMED, Error::DirRawRejected => E_DIR_RAW_REJECTED, Error::DirMultipassRejected => E_DIR_MULTIPASS_REJECTED, Error::DirSourceUnsupported => E_DIR_SOURCE_UNSUPPORTED, Error::DirNotEmpty => E_DIR_NOT_EMPTY, Error::DirInsufficientSpace { .. } => E_DIR_INSUFFICIENT_SPACE, Error::DirNameCollision { .. } => E_DIR_NAME_COLLISION, Error::DirWriteFailed { .. } => E_DIR_WRITE_FAILED, } } } /// Display: "E{code}" with structured data. No English words. impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Error::DeviceNotFound { path } => write!(f, "E{}: {}", self.code(), path), Error::DevicePermission { path } => write!(f, "E{}: {}", self.code(), path), Error::DeviceNotReady { path } => write!(f, "E{}: {}", self.code(), path), Error::DeviceResetFailed { path } => write!(f, "E{}: {}", self.code(), path), Error::ScsiInterfaceUnavailable { path } => write!(f, "E{}: {}", self.code(), path), Error::DeviceLocked { path, kr } => { write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr) } Error::IoKitPluginFailed { path, kr } => { write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr) } Error::UnsupportedPlatform { target } => { write!(f, "E{}: {}", self.code(), target) } Error::PlatformNotImplemented { platform } => { write!(f, "E{}: {}", self.code(), platform) } Error::MapfileInvalid { kind } => { write!(f, "E{}: {}", self.code(), kind) } Error::UnsupportedDrive { vendor_id, product_id, product_revision, } => write!( f, "E{}: {} {} {}", self.code(), vendor_id.trim(), product_id.trim(), product_revision.trim() ), Error::SignatureMismatch { expected, got } => write!( f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}", self.code(), expected[0], expected[1], expected[2], expected[3], got[0], got[1], got[2], got[3] ), Error::ScsiError { opcode, status, sense, } => match sense { Some(s) => write!( f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, s.sense_key, s.asc, s.ascq, ), None => write!(f, "E{}: 0x{:02x}/0x{:02x}", self.code(), opcode, status,), }, // Language-neutral: std::io::Error's Display is English // ("permission denied"); emit the raw OS errno when present, // else the ErrorKind debug name (an identifier, not prose). Error::IoError { source } => match source.raw_os_error() { Some(errno) => write!(f, "E{}: {}", self.code(), errno), None => write!(f, "E{}: {:?}", self.code(), source.kind()), }, Error::DiscRead { sector, status, sense, } => match (status, sense) { (Some(st), Some(s)) => write!( f, "E{}: {} 0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}", self.code(), sector, st, s.sense_key, s.asc, s.ascq, ), (Some(st), None) => write!(f, "E{}: {} 0x{:02x}", self.code(), sector, st,), (None, Some(s)) => write!( f, "E{}: {} 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), sector, s.sense_key, s.asc, s.ascq, ), (None, None) => write!(f, "E{}: {}", self.code(), sector), }, Error::Halted => write!(f, "E{}", self.code()), Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path), Error::DiscTitleRange { index, count } => { write!(f, "E{}: {}/{}", self.code(), index, count) } Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host), Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status), Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path), Error::KeydbLoad { path } => write!(f, "E{}: {}", self.code(), path), Error::AacsNoHostCert { path } => write!(f, "E{}: {}", self.code(), path), Error::KeydbUnsupportedScheme { scheme } => { write!(f, "E{}: {}", self.code(), scheme) } Error::StreamUrlInvalid { url } => write!(f, "E{}: {}", self.code(), url), Error::StreamUrlMissingPath { scheme } => write!(f, "E{}: {}", self.code(), scheme), Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr), Error::NetworkAddrBlocked { addr } => write!(f, "E{}: {}", self.code(), addr), Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size), Error::PesTrackTooLarge { track } => write!(f, "E{}: {}", self.code(), track), Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path), Error::NoDiscKey { disc_hash } => { if disc_hash.is_empty() { write!(f, "E{}", self.code()) } else { write!(f, "E{}: {}", self.code(), disc_hash) } } Error::MuxTrackRange { track, tracks } => { write!(f, "E{}: {}/{}", self.code(), track, tracks) } Error::InvalidCdbLength { len, max } => { write!(f, "E{}: {}/{}", self.code(), len, max) } _ => write!(f, "E{}", self.code()), } } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Error::IoError { source } => Some(source), _ => None, } } } impl From for Error { fn from(e: std::io::Error) -> Self { Error::IoError { source: e } } } impl From for std::io::Error { fn from(e: Error) -> Self { // An `Error::IoError` is just a wrapper around an underlying // `io::Error` that entered via `From for Error`. // Round-trip it back unchanged so the original `ErrorKind` and // raw OS error code survive instead of being flattened to // `Other` with a stringified message. if let Error::IoError { source } = e { return source; } let code = e.code(); let msg = e.to_string(); // Map our error categories to io::ErrorKind let kind = match code { // Device access-denied semantics map to PermissionDenied; // the rest of the 1xxx block is "device absent" -> NotFound. E_DEVICE_PERMISSION | E_DEVICE_LOCKED => std::io::ErrorKind::PermissionDenied, 1000..=1999 => std::io::ErrorKind::NotFound, 2000..=2999 => std::io::ErrorKind::Unsupported, 3000..=3999 => std::io::ErrorKind::PermissionDenied, 4000..=4999 => std::io::ErrorKind::Other, 5000..=5999 => std::io::ErrorKind::Other, 6000..=6999 => std::io::ErrorKind::InvalidData, 7000..=7999 => std::io::ErrorKind::PermissionDenied, 8000..=8999 => std::io::ErrorKind::Other, 9000..=9001 => std::io::ErrorKind::Unsupported, 9002..=9008 => std::io::ErrorKind::InvalidInput, // 9009 DiscUrlNotDirect: structurally unsupported entry point, // not a parse failure — caller used the wrong API. 9009 => std::io::ErrorKind::Unsupported, // 9010 HevcParamParse: malformed hvcC payload. 9010 => std::io::ErrorKind::InvalidData, // 9011 MuxTrackRange: caller passed a bad track index. 9011 => std::io::ErrorKind::InvalidInput, // 9012 Fmp4Unimplemented: sink can't emit media yet. 9012 => std::io::ErrorKind::Unsupported, // 9014 PipelineJoinTimeout: consumer drain exceeded deadline. E_PIPELINE_JOIN_TIMEOUT => std::io::ErrorKind::TimedOut, // 9017 PesTrackTooLarge: out-of-range track index on serialize. 9017 => std::io::ErrorKind::InvalidInput, // 9020 DiscCapacityOverflow: disc reported a capacity sentinel // we can't represent — treat as bad/invalid device data. 9020 => std::io::ErrorKind::InvalidData, // 9021 M2tsPacketMalformed: a muxer invariant break produced // a non-188-byte packet — treat as invalid data. 9021 => std::io::ErrorKind::InvalidData, // 9022 NetworkAddrBlocked: the output host resolved only to // blocked (loopback/private/link-local) addresses — refuse. E_NETWORK_ADDR_BLOCKED => std::io::ErrorKind::PermissionDenied, // 9023 MuxEmpty: finish() reached with zero frames — the output // would be a header-only container. Treat as invalid output. E_MUX_EMPTY => std::io::ErrorKind::InvalidData, // 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned // extent was handed to the prefetch producer. 9030 => std::io::ErrorKind::InvalidInput, // 9047 DiscCapacityMalformed: the drive returned an unusable // READ CAPACITY response (short transfer / overflow). 9047 => std::io::ErrorKind::InvalidData, // dir:// usage / footgun gates (9019, 9024–9026, 9028): the caller // gave an invalid flag/source/name combination — InvalidInput. E_DIR_RAW_REJECTED | E_DIR_MULTIPASS_REJECTED | E_DIR_SOURCE_UNSUPPORTED | E_DIR_NOT_EMPTY | E_DIR_NAME_COLLISION => std::io::ErrorKind::InvalidInput, // 9027 insufficient space / 9029 write failed: a filesystem-level // failure, not bad input. E_DIR_INSUFFICIENT_SPACE | E_DIR_WRITE_FAILED => std::io::ErrorKind::Other, _ => std::io::ErrorKind::Other, }; std::io::Error::new(kind, msg) } } /// Convenience alias for `Result`. pub type Result = std::result::Result; impl Error { /// Borrow the drive-returned SPC-4 sense triple if this error is a /// [`Error::ScsiError`] carrying sense data. `None` for any other /// variant **and** for `ScsiError`s that represent a transport-layer /// failure (where the device never delivered a SCSI status reply, so /// no sense data exists). pub fn scsi_sense(&self) -> Option<&crate::scsi::ScsiSense> { match self { Error::ScsiError { sense: Some(s), .. } => Some(s), Error::DiscRead { sense: Some(s), .. } => Some(s), _ => None, } } /// True if this is a [`Error::ScsiError`] representing a transport-layer /// failure — kernel timeout, USB bridge wedge, IOKit service error. /// The device never delivered a SCSI status reply, so there is no /// sense data to inspect; retrying typically requires physical /// intervention (replug). pub fn is_scsi_transport_failure(&self) -> bool { matches!( self, Error::ScsiError { status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, .. } ) || matches!( self, Error::DiscRead { status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE), .. } ) || matches!( // A failed `ioctl(SG_IO)` (Error::IoError, e.g. ENODEV/EIO on an // unplugged USB bridge) and a vanished device (Error::DeviceNotFound, // fd gone) are dead-bus / transport-layer faults too — NOT recoverable // bad sectors. Treat them as transport failures so sweep / patch / // fill_extents abort the pass and re-enumerate the bridge instead of // zero-filling every read against a wedged device. self, Error::IoError { .. } | Error::DeviceNotFound { .. } ) } /// True if this error indicates bridge degradation — the SCSI status /// is neither GOOD (0x00), CHECK CONDITION (0x02), nor transport failure /// (0xFF). Observed on the Initio INIC-1618L USB bridge preceding a full /// crash: the bridge firmware returns non-standard status bytes (e.g. /// 0x04, 0x05) with empty sense data. The caller should cool down /// (10 s pause) and retry rather than hammering the bridge. pub fn is_bridge_degradation(&self) -> bool { let status = match self { Error::ScsiError { status, .. } => *status, Error::DiscRead { status, .. } => status.unwrap_or(0), _ => return false, }; status != crate::scsi::SCSI_STATUS_GOOD && status != crate::scsi::SCSI_STATUS_CHECK_CONDITION && status != crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE } /// True if the underlying SCSI failure is a *marginal read* — the /// drive returned an error category in which smaller-granularity /// retries can sometimes recover the data: /// /// - MEDIUM ERROR (sense key 3) — canonical bad-sector signal /// - ABORTED COMMAND (sense key B) — transient; retry usually works /// - NOT READY (sense key 2) — the dominant bad-sector response on /// the BU40N (ASC 0x04/ASCQ 0x3E); a pause + retry often recovers /// - RECOVERED ERROR (sense key 1) / NO SENSE (sense key 0) — not /// classified as fatal; treat as recoverable /// /// Returns `false` for transport failures (no sense data delivered), /// HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, ILLEGAL /// REQUEST, BLANK CHECK, kernel `IoError`, and any non-SCSI variant. /// Caller-agnostic predicate — describes a property of the *error*, /// not what one specific call site should do with it. Used by /// `Disc::copy`'s hysteresis dispatch. pub fn is_marginal_read(&self) -> bool { self.scsi_sense() .map(crate::scsi::ScsiSense::is_marginal) .unwrap_or(false) } } #[cfg(test)] mod tests { //! Smoke tests for the error code → variant mapping. Each new variant //! added in 0.13.0 (English-elimination work) gets a code() check + a //! Display sanity-check (no English words) + an io::ErrorKind mapping //! check. Without these, future drift between the const codes and the //! match arms in `code()` / the From impl could silently miscategorize. use super::*; #[test] fn new_variants_have_distinct_codes() { let codes = [ Error::ScsiInterfaceUnavailable { path: "p".into() }.code(), Error::DeviceLocked { path: "p".into(), kr: 0, } .code(), Error::IoKitPluginFailed { path: "p".into(), kr: 0, } .code(), Error::UnsupportedPlatform { target: "x".into() }.code(), Error::PlatformNotImplemented { platform: "renesas".into(), } .code(), Error::MapfileInvalid { kind: "hex" }.code(), Error::DiscUrlNotDirect.code(), Error::ExtentNotUnitAligned.code(), Error::M2tsPacketMalformed.code(), Error::DiscCapacityMalformed.code(), Error::DirRawRejected.code(), Error::DirMultipassRejected.code(), Error::DirSourceUnsupported.code(), Error::DirNotEmpty.code(), Error::DirInsufficientSpace { required: 1, available: 0, } .code(), Error::DirNameCollision { host: "x".into() }.code(), Error::DirWriteFailed { errno: Some(28) }.code(), ]; let mut sorted = codes.to_vec(); sorted.sort(); sorted.dedup(); assert_eq!( sorted.len(), codes.len(), "two new variants share a code — check error.rs constants" ); } #[test] fn display_emits_no_english_words() { // Every variant's Display must be `E{code}: {data}` — no English. // Sample a few of the new variants and a few existing ones to // catch accidental string-stuffing in future edits. let cases: &[(Error, u16)] = &[ ( Error::ScsiInterfaceUnavailable { path: "/dev/sg4".into(), }, E_SCSI_INTERFACE_UNAVAILABLE, ), ( Error::DeviceLocked { path: "/dev/sg4".into(), kr: 0xE00002C5, }, E_DEVICE_LOCKED, ), ( Error::UnsupportedPlatform { target: "freebsd".into(), }, E_UNSUPPORTED_PLATFORM, ), ( Error::PlatformNotImplemented { platform: "renesas".into(), }, E_PLATFORM_NOT_IMPLEMENTED, ), (Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID), (Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT), (Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED), ]; for (e, want_code) in cases { let s = e.to_string(); assert!( s.starts_with(&format!("E{}", want_code)), "{:?} display does not lead with code: {}", e, s ); // Crude English filter — `Display` should never emit ASCII words // longer than 4 chars (codes/paths/identifiers like `/dev/sg4`, // `renesas`, `freebsd` all pass; "exclusive access denied" would // not). for word in s.split(|c: char| !c.is_ascii_alphabetic()) { assert!( word.len() <= 8, "Display contains suspicious English-looking word `{word}` in `{s}`" ); } } } #[test] fn iokind_mapping_for_new_variants() { use std::io::ErrorKind; let mapped = |e: Error| -> ErrorKind { let io: std::io::Error = e.into(); io.kind() }; // 1xxx "device absent" → NotFound assert_eq!( mapped(Error::ScsiInterfaceUnavailable { path: "p".into() }), ErrorKind::NotFound ); assert_eq!( mapped(Error::DeviceNotFound { path: "p".into() }), ErrorKind::NotFound ); // 1xxx access-denied semantics → PermissionDenied (not NotFound) assert_eq!( mapped(Error::DevicePermission { path: "p".into() }), ErrorKind::PermissionDenied ); assert_eq!( mapped(Error::DeviceLocked { path: "p".into(), kr: 0 }), ErrorKind::PermissionDenied ); // 2xxx range → Unsupported assert_eq!( mapped(Error::UnsupportedPlatform { target: "x".into() }), ErrorKind::Unsupported ); assert_eq!( mapped(Error::PlatformNotImplemented { platform: "x".into() }), ErrorKind::Unsupported ); // 6xxx range → InvalidData assert_eq!( mapped(Error::MapfileInvalid { kind: "hex" }), ErrorKind::InvalidData ); // 9009 special-cased to Unsupported assert_eq!(mapped(Error::DiscUrlNotDirect), ErrorKind::Unsupported); // 9021 special-cased to InvalidData assert_eq!(mapped(Error::M2tsPacketMalformed), ErrorKind::InvalidData); // 9047 DiscCapacityMalformed → InvalidData assert_eq!(mapped(Error::DiscCapacityMalformed), ErrorKind::InvalidData); } /// `Error::IoError` must round-trip back to the *original* /// `io::Error` — preserving its `ErrorKind` and raw OS error — /// rather than being flattened to `Other` with a stringified /// message. #[test] fn ioerror_roundtrips_preserving_kind_and_oscode() { use std::io::ErrorKind; let original = std::io::Error::from_raw_os_error(13); // EACCES let original_kind = original.kind(); let wrapped: Error = original.into(); // From for Error let back: std::io::Error = wrapped.into(); // From for io::Error assert_eq!(back.kind(), original_kind); assert_eq!(back.raw_os_error(), Some(13)); // A synthesized kind (no OS code) must also survive. let timeout: Error = std::io::Error::from(ErrorKind::TimedOut).into(); let back2: std::io::Error = timeout.into(); assert_eq!(back2.kind(), ErrorKind::TimedOut); } /// `DiscRead` Display must include the ASCQ byte (the 5th field) so /// NOT_READY substates (0x04/0x3E vs 0x04/0x01) are distinguishable /// in logs and bug reports. #[test] fn discread_display_includes_ascq() { let e = Error::DiscRead { sector: 42, status: Some(0x02), sense: Some(crate::scsi::ScsiSense { sense_key: 0x02, asc: 0x04, ascq: 0x3e, }), }; let s = e.to_string(); // sense_key/asc/ascq triple all present. assert!(s.contains("0x02/0x04/0x3e"), "ascq missing from `{s}`"); } /// `NoDiscKey` with an empty hash must not emit a dangling /// "colon space" suffix. #[test] fn nodisckey_empty_hash_has_no_trailing_colon() { let e = Error::NoDiscKey { disc_hash: String::new(), }; assert_eq!(e.to_string(), format!("E{}", E_NO_DISC_KEY)); let e2 = Error::NoDiscKey { disc_hash: "abc".into(), }; 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_INVALID_CDB_LENGTH, 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_CSS_KEY_MISSING, E_AACS_NO_HOST_CERT, E_AACS_BUS_KEY_UNAVAILABLE, E_FMTS_KEY_MISSING, 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_NETWORK_ADDR_BLOCKED, E_MUX_EMPTY, 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 1000–1999, all AACS codes are 7000–7999. /// 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::MuxEmpty, E_MUX_EMPTY), (Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED), (Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED), (Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED), ( Error::NetworkAddrBlocked { addr: String::new(), }, E_NETWORK_ADDR_BLOCKED, ), ]; 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 is true for the 0xFF SCSI sentinel AND for the /// non-SCSI dead-bus faults (Error::IoError from a failed ioctl(SG_IO), /// Error::DeviceNotFound from a vanished fd) — but NEVER for a real SCSI /// reply (CHECK CONDITION) or unrelated errors. /// Mutation: testing against 0x02 (CHECK CONDITION) would wrongly mark CHECK /// CONDITION replies as transport failures; dropping the IoError/ /// DeviceNotFound arm would let a dead bus zero-fill the disc. #[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()); // True: non-SCSI dead-bus faults — a failed ioctl(SG_IO) and a vanished // device are transport-layer failures, not recoverable bad sectors. assert!( Error::IoError { source: std::io::Error::from(std::io::ErrorKind::NotConnected) } .is_scsi_transport_failure() ); assert!( Error::DeviceNotFound { path: "/dev/sg9".into() } .is_scsi_transport_failure() ); // False for unrelated 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: "".into(), }; assert!( e_load.to_string().contains(""), "KeydbLoad display must include the sentinel path" ); let e_no_cert = Error::AacsNoHostCert { path: "".into(), }; assert!( e_no_cert.to_string().contains(""), "AacsNoHostCert 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); } }