When the drive is in extended-access state (unlocked), retrieve VID via the per-drive `read_vid_cdb` from the bundled profile instead of the cert-based AACS REPORT_KEY handshake. Cert handshake remains the fallback for drives that don't enter extended-access state, or whose profile lacks the required CDB. Empirically verified on the BU40N (signature 999ec375) against Barbie UHD: drive returns 36 bytes from buffer 0x44 at offset 0x10E291, VID at response[4..20]. The 16 bytes match Dune Part Two's known VID in keydb.cfg byte-for-byte, cross-validating the path against an independent oracle. Architectural impact: - Renames `Drive::is_libredrive_active()` → `Drive::is_unlocked()`. Internal `Mt1959::libredrive_active` becomes `Mt1959::unlocked`; the prior `unlocked` (init-success flag) becomes `init_complete` to avoid the name collision. - `disc/encrypt.rs::Disc::read_vid` is the single entry point. When `is_unlocked()` is true, calls `read_vid_oem` (issues the per-drive CDB, validates the response signature high-3-bytes `00 22 00`, returns bytes [4..20]). Otherwise delegates to `read_vid_cert` (the existing AACS REPORT_KEY format 0x80 path). - `DriveProfile` gains the per-drive CDB templates and identifier blocks extracted from each per-drive firmware payload — including `read_vid_cdb`, `read_disc_keys_cdb`, `drive_nominal_speed_cdb`, `set_speed_max_cdb`, two cache-prime canary CDBs, the buffer-0x45 verify CDB, the firmware-upload CDB, and the unlock probe CDB. Variants A and B differ in which fields are populated. All optional; consumers fall back to the cert/handshake path when fields are absent. - New error variants `Error::DriveProfileMissing` (E7020) and `Error::VidCdbUnavailable` (E7021). Both treated as "OEM unavailable → try cert path" by `read_vid`, not terminal. Closes the v0.25.x gap where HRL-burned host certs (the public libaacs leaked cert is on every recent drive's HRL) blocked all post-handshake VID retrieval. With OEM-driven VID: - AACS 1.0 BD on supported drives: rips end-to-end with our existing DKs walking the MKB. - AACS 2.x UHD: fails honestly at the DK wall (E7018 "No usable DK" for v77+ MKBs) instead of the misleading E7017 "No Volume ID" the prior code surfaced. We have VID; we just don't have v77+ DK material — that gap is a key-acquisition problem, not a code problem. Empirically verified on rip1 (BU40N + Barbie UHD, MKB v77, 2026-05-21): error code flipped from E7017 to E7018 as predicted. The DK wall is now correctly the proximate failure for unrippable modern UHD discs, instead of the indirect VID-retrieval wall the v0.25.x cert-only path produced. Renames and comment scrubs eliminate upstream-RE-vocabulary references in the public crate per `feedback_no_breadcrumbs.md`. 674 tests pass (565 lib + 109 integration). No tradename leaks in any modified file.
294 lines
9.5 KiB
Rust
294 lines
9.5 KiB
Rust
//! Drive profile loading and matching.
|
|
|
|
use crate::error::{Error, Result};
|
|
use serde::Deserialize;
|
|
|
|
/// Top-level profiles file — keyed by chipset + variant.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ProfilesFile {
|
|
#[serde(default)]
|
|
pub mt1959_a: Vec<DriveProfile>,
|
|
#[serde(default)]
|
|
pub mt1959_b: Vec<DriveProfile>,
|
|
#[serde(default)]
|
|
pub renesas: Vec<DriveProfile>,
|
|
}
|
|
|
|
/// Drive identity — matched against INQUIRY data.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Identity {
|
|
#[serde(default)]
|
|
pub vendor_id: String,
|
|
#[serde(default)]
|
|
pub product_revision: String,
|
|
#[serde(default)]
|
|
pub vendor_specific: String,
|
|
#[serde(default)]
|
|
pub firmware_date: String,
|
|
}
|
|
|
|
/// Per-drive profile.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct DriveProfile {
|
|
pub identity: Identity,
|
|
#[serde(default, deserialize_with = "deserialize_hex4")]
|
|
pub signature: [u8; 4],
|
|
#[serde(default, deserialize_with = "deserialize_base64")]
|
|
pub firmware: Vec<u8>,
|
|
|
|
// ── OEM-extended-access CDB templates ──────────────────────────────
|
|
//
|
|
// All optional — older profile blobs that pre-date the CDB capture
|
|
// pipeline simply omit these fields and decode as `None`. Encoded
|
|
// in the JSON as lowercase hex strings without separators
|
|
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
|
|
#[serde(default)]
|
|
pub unlock_init_value: u8,
|
|
#[serde(default)]
|
|
pub unlock_response_size: u8,
|
|
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
|
pub read_vid_cdb: Option<[u8; 10]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
|
pub read_disc_keys_cdb: Option<[u8; 10]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
|
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
|
pub set_speed_max_cdb: Option<[u8; 12]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
|
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
|
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
|
pub read_buffer_verify_cdb: Option<[u8; 10]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
|
pub write_buffer_cdb: Option<[u8; 10]>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
|
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
|
|
|
|
// Per-drive identifier tables — variable-length hex strings.
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
|
pub speed_zone_table: Option<Vec<u8>>,
|
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
|
pub speed_calc_table: Option<Vec<u8>>,
|
|
}
|
|
|
|
/// Chipset + variant — determined by which section the profile was found in.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum Platform {
|
|
Mt1959A,
|
|
Mt1959B,
|
|
Renesas,
|
|
}
|
|
|
|
impl Platform {
|
|
pub fn name(&self) -> &'static str {
|
|
match self {
|
|
Platform::Mt1959A => "MediaTek MT1959",
|
|
Platform::Mt1959B => "MediaTek MT1959",
|
|
Platform::Renesas => "Renesas",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of profile lookup.
|
|
pub struct ProfileMatch {
|
|
pub profile: DriveProfile,
|
|
pub platform: Platform,
|
|
}
|
|
|
|
// ── Parsing ────────────────────────────────────────────────────────────
|
|
|
|
fn parse_hex4(s: &str) -> Result<[u8; 4]> {
|
|
if s.len() != 8 {
|
|
return Err(Error::ProfileParse);
|
|
}
|
|
let mut out = [0u8; 4];
|
|
for i in 0..4 {
|
|
out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).map_err(|_| Error::ProfileParse)?;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn deserialize_hex4<'de, D>(deserializer: D) -> std::result::Result<[u8; 4], D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let s = String::deserialize(deserializer)?;
|
|
if s.is_empty() {
|
|
return Ok([0; 4]);
|
|
}
|
|
parse_hex4(&s).map_err(serde::de::Error::custom)
|
|
}
|
|
|
|
fn deserialize_base64<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use base64::Engine;
|
|
let s = String::deserialize(deserializer)?;
|
|
if s.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
base64::engine::general_purpose::STANDARD
|
|
.decode(&s)
|
|
.map_err(serde::de::Error::custom)
|
|
}
|
|
|
|
// ── Fixed-length hex deserializers for CDB templates ────────────────────
|
|
//
|
|
// Profile JSON encodes CDBs as lowercase hex strings without separators.
|
|
// An empty string / null / missing field decodes as `None`.
|
|
|
|
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
|
if s.len() % 2 != 0 {
|
|
return Err("odd hex length");
|
|
}
|
|
let mut out = Vec::with_capacity(s.len() / 2);
|
|
for i in (0..s.len()).step_by(2) {
|
|
let byte = u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex digit")?;
|
|
out.push(byte);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn deserialize_opt_hex_bytes_10<'de, D>(
|
|
deserializer: D,
|
|
) -> std::result::Result<Option<[u8; 10]>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let opt: Option<String> = Option::deserialize(deserializer)?;
|
|
let Some(s) = opt else { return Ok(None) };
|
|
if s.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
|
if bytes.len() != 10 {
|
|
return Err(serde::de::Error::custom("expected 10 bytes"));
|
|
}
|
|
let mut out = [0u8; 10];
|
|
out.copy_from_slice(&bytes);
|
|
Ok(Some(out))
|
|
}
|
|
|
|
fn deserialize_opt_hex_bytes_12<'de, D>(
|
|
deserializer: D,
|
|
) -> std::result::Result<Option<[u8; 12]>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let opt: Option<String> = Option::deserialize(deserializer)?;
|
|
let Some(s) = opt else { return Ok(None) };
|
|
if s.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
|
if bytes.len() != 12 {
|
|
return Err(serde::de::Error::custom("expected 12 bytes"));
|
|
}
|
|
let mut out = [0u8; 12];
|
|
out.copy_from_slice(&bytes);
|
|
Ok(Some(out))
|
|
}
|
|
|
|
fn deserialize_opt_hex_bytes<'de, D>(
|
|
deserializer: D,
|
|
) -> std::result::Result<Option<Vec<u8>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let opt: Option<String> = Option::deserialize(deserializer)?;
|
|
let Some(s) = opt else { return Ok(None) };
|
|
if s.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
|
Ok(Some(bytes))
|
|
}
|
|
|
|
// ── Loading ────────────────────────────────────────────────────────────
|
|
|
|
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
|
|
|
pub fn load_bundled() -> Result<ProfilesFile> {
|
|
load_from_str(BUNDLED_PROFILES)
|
|
}
|
|
|
|
fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
|
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
|
|
}
|
|
|
|
/// Find a profile matching a drive's INQUIRY fields.
|
|
pub fn find_by_drive_id(
|
|
profiles: &ProfilesFile,
|
|
drive_id: &crate::identity::DriveId,
|
|
) -> Option<ProfileMatch> {
|
|
let v = drive_id.vendor_id.trim();
|
|
let r = drive_id.product_revision.trim();
|
|
let vs = drive_id.vendor_specific.trim();
|
|
let date = drive_id.firmware_date.trim();
|
|
|
|
for (platform, list) in [
|
|
(Platform::Mt1959A, &profiles.mt1959_a),
|
|
(Platform::Mt1959B, &profiles.mt1959_b),
|
|
(Platform::Renesas, &profiles.renesas),
|
|
] {
|
|
if let Some(p) = list.iter().find(|p| {
|
|
p.identity.vendor_id.trim() == v
|
|
&& p.identity.product_revision.trim() == r
|
|
&& p.identity.vendor_specific.trim() == vs
|
|
&& p.identity.firmware_date.trim() == date
|
|
}) {
|
|
return Some(ProfileMatch {
|
|
profile: p.clone(),
|
|
platform,
|
|
});
|
|
}
|
|
|
|
if let Some(p) = list.iter().find(|p| {
|
|
p.identity.vendor_id.trim() == v
|
|
&& p.identity.product_revision.trim() == r
|
|
&& p.identity.vendor_specific.trim() == vs
|
|
}) {
|
|
return Some(ProfileMatch {
|
|
profile: p.clone(),
|
|
platform,
|
|
});
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::identity::DriveId;
|
|
|
|
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
|
let mut inquiry = vec![0u8; 96];
|
|
inquiry[8..8 + vendor.len().min(8)]
|
|
.copy_from_slice(&vendor.as_bytes()[..vendor.len().min(8)]);
|
|
inquiry[32..32 + rev.len().min(4)].copy_from_slice(&rev.as_bytes()[..rev.len().min(4)]);
|
|
inquiry[36..36 + vs.len().min(7)].copy_from_slice(&vs.as_bytes()[..vs.len().min(7)]);
|
|
DriveId::from_inquiry(&inquiry, date)
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_known_drive() {
|
|
let profiles = load_bundled().unwrap();
|
|
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
|
let m = find_by_drive_id(&profiles, &id).unwrap();
|
|
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
|
|
assert_eq!(m.platform, Platform::Mt1959A);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_unknown_drive() {
|
|
let profiles = load_bundled().unwrap();
|
|
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
|
|
assert!(find_by_drive_id(&profiles, &id).is_none());
|
|
}
|
|
}
|