When an AACS-encrypted ISO is muxed with decryption requested (not --raw)
but key resolution yielded no usable key, input() proceeded to mux the
still-encrypted stream — emitting ~100 MB of garbage (no TS syncs, demuxer
emits nothing) and sometimes spinning for tens of minutes.
Add a cheap result-check in resolve::input()'s Iso branch via the pure
predicate aacs_key_missing(raw, has_aacs, keys): when decryption is
requested AND the disc carries AACS state AND decrypt_keys() is None,
return new Error::NoDiscKey { disc_hash } (E7022) before muxing. The
40-hex disc hash is sourced from AacsState::disc_hash. --raw and
non-AACS (unencrypted / CSS) discs are unaffected. Unit-tested.
746 lines
27 KiB
Rust
746 lines
27 KiB
Rust
//! 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;
|
|
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;
|
|
|
|
// I/O (5xxx)
|
|
pub const E_IO_ERROR: u16 = 5000;
|
|
|
|
// Disc format (6xxx)
|
|
pub const E_DISC_READ: u16 = 6000;
|
|
pub const E_HALTED: u16 = 6010;
|
|
pub const E_MPLS_PARSE: u16 = 6001;
|
|
pub const E_CLPI_PARSE: u16 = 6002;
|
|
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
|
pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
|
pub const E_IFO_PARSE: u16 = 6007;
|
|
pub const E_MKV_INVALID: u16 = 6008;
|
|
pub const E_NO_STREAMS: u16 = 6009;
|
|
pub const E_MAPFILE_INVALID: u16 = 6011;
|
|
|
|
// 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;
|
|
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;
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
|
|
// ── Error enum ──────────────────────────────────────────────────────────────
|
|
|
|
/// Structured error with numeric code and context data. No English text.
|
|
#[derive(Debug)]
|
|
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<crate::scsi::ScsiSense>,
|
|
},
|
|
|
|
// I/O (5xxx)
|
|
IoError {
|
|
source: std::io::Error,
|
|
},
|
|
|
|
// Disc format (6xxx)
|
|
DiscRead {
|
|
sector: u64,
|
|
status: Option<u8>,
|
|
sense: Option<crate::scsi::ScsiSense>,
|
|
},
|
|
/// Drive was halted by caller.
|
|
Halted,
|
|
MplsParse,
|
|
ClpiParse,
|
|
UdfNotFound {
|
|
path: String,
|
|
},
|
|
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,
|
|
},
|
|
|
|
// Keydb (8xxx)
|
|
KeydbConnect {
|
|
host: String,
|
|
},
|
|
KeydbHttp {
|
|
status: u16,
|
|
},
|
|
KeydbInvalid,
|
|
KeydbWrite {
|
|
path: String,
|
|
},
|
|
KeydbParse,
|
|
KeydbLoad {
|
|
path: String,
|
|
},
|
|
|
|
// Stream/mux (9xxx)
|
|
StreamReadOnly,
|
|
StreamWriteOnly,
|
|
StreamUrlInvalid {
|
|
url: String,
|
|
},
|
|
StreamUrlMissingPath {
|
|
scheme: String,
|
|
},
|
|
StreamUrlMissingPort {
|
|
addr: String,
|
|
},
|
|
PesFrameTooLarge {
|
|
size: usize,
|
|
},
|
|
PesInvalidMagic,
|
|
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,
|
|
}
|
|
|
|
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::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::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::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::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::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
|
|
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
|
Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE,
|
|
Error::NoMetadata => E_NO_METADATA,
|
|
Error::DiscUrlNotDirect => E_DISC_URL_NOT_DIRECT,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,),
|
|
},
|
|
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
|
|
Error::DiscRead {
|
|
sector,
|
|
status,
|
|
sense,
|
|
} => match (status, sense) {
|
|
(Some(st), Some(s)) => write!(
|
|
f,
|
|
"E{}: {} 0x{:02x}/0x{:02x}/0x{:02x}",
|
|
self.code(),
|
|
sector,
|
|
st,
|
|
s.sense_key,
|
|
s.asc,
|
|
),
|
|
(Some(st), None) => write!(f, "E{}: {} 0x{:02x}", self.code(), sector, st,),
|
|
(None, Some(s)) => write!(
|
|
f,
|
|
"E{}: {} 0x{:02x}/0x{:02x}",
|
|
self.code(),
|
|
sector,
|
|
s.sense_key,
|
|
s.asc,
|
|
),
|
|
(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::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::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size),
|
|
Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path),
|
|
Error::NoDiscKey { disc_hash } => write!(f, "E{}: {}", self.code(), disc_hash),
|
|
_ => 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<std::io::Error> for Error {
|
|
fn from(e: std::io::Error) -> Self {
|
|
Error::IoError { source: e }
|
|
}
|
|
}
|
|
|
|
impl From<Error> for std::io::Error {
|
|
fn from(e: Error) -> Self {
|
|
let code = e.code();
|
|
let msg = e.to_string();
|
|
// Map our error categories to io::ErrorKind
|
|
let kind = match code {
|
|
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,
|
|
_ => std::io::ErrorKind::Other,
|
|
};
|
|
std::io::Error::new(kind, msg)
|
|
}
|
|
}
|
|
|
|
/// Convenience alias for `Result<T, Error>`.
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
|
|
|
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),
|
|
..
|
|
}
|
|
)
|
|
}
|
|
|
|
/// 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
|
|
/// - 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(),
|
|
];
|
|
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),
|
|
];
|
|
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
|
|
|| word.eq_ignore_ascii_case("renesas")
|
|
|| word.eq_ignore_ascii_case("freebsd"),
|
|
"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 range → NotFound
|
|
assert_eq!(
|
|
mapped(Error::ScsiInterfaceUnavailable { path: "p".into() }),
|
|
ErrorKind::NotFound
|
|
);
|
|
assert_eq!(
|
|
mapped(Error::DeviceLocked {
|
|
path: "p".into(),
|
|
kr: 0
|
|
}),
|
|
ErrorKind::NotFound
|
|
);
|
|
// 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);
|
|
}
|
|
}
|