mediatek-optical 0.6.0

MediaTek MT1959/MT1939 optical drive (BD/DVD) protocol: command encoding, identity and flash sequences over any SCSI transport, chip detection, hardware families, AES-CMAC integrity, family-gated updates and OEM image reconstruction. no_std by default.
Documentation
//! Chip generation and identity detection from image bytes.
//!
//! The authoritative family key is the ASCII **`MTEKMT19xx`** identity string,
//! found by *pattern-searching* the whole image (`MTEKMT19` + two digits):
//!
//! * `MTEKMT1959` → [`Chip::Mt1959`], `MTEKMT1939` → [`Chip::Mt1939`].
//!   The string appears both as the drive-reported identity and mirrored near the
//!   descriptor at [`layout::DESCRIPTOR`] (`0x1EC034`); pattern-searching finds it
//!   even when the descriptor page is byte-shifted (some ASUS extractions), where
//!   a fixed-offset read would miss.
//!
//! Two things that *look* like family signals are deliberately NOT used to gate
//! the family (proven across a 149-image corpus scan):
//!
//! * the boot **banner** at [`layout::BANNER`] (`0x3000`), e.g. `"MT1959 Boot
//!   JB8 "`, labels the *bootloader generation*, not the silicon — it reads
//!   `MT1959 Boot` even on `MTEKMT1939` parts (ASUS BW-16D1HT, LG BH16NS58, …).
//!   It is used only as a *fallback* when no `MTEKMT19xx` string exists at all
//!   (e.g. some Samsung MT1939 images), and otherwise is display-only;
//! * the descriptor **marker byte** at `+0x50` takes `0x78/0x58/0x18/0x38` within
//!   a single family (a variant/region/downgrade flag beside the DE byte at
//!   `+0x56`) — display-only, never a gate.
//!
//! Detection is fail-open on family but still refuses cleanly on truly
//! unidentifiable input (no `MTEKMT19xx` string and no family banner): callers
//! then abort with a clear message rather than guessing.

use crate::layout;
use crate::Chip;

const BANNER_OFFSET: usize = layout::BANNER.start;
const BANNER_LEN: usize = layout::BANNER.len;
const DESCRIPTOR_OFFSET: usize = layout::DESCRIPTOR.start;
/// Bytes of the descriptor record consumed by parsing.
const DESCRIPTOR_LEN: usize = 0x40;
/// Offset of the descriptor's `MTEKMT19..` family tag within the record.
const DESCRIPTOR_TAG_OFF: usize = 0x34;
/// Offset of the display-only marker byte within the descriptor record.
const DESCRIPTOR_MARKER_OFF: usize = 0x50;

/// How the family was decided — for display/audit and to tell a strong
/// identity (the `MTEKMT19xx` string) from a weak one (banner fallback).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
    /// Decided by the authoritative `MTEKMT19xx` identity string.
    TagString,
    /// No identity string present; decided by the (bootloader-generation) banner.
    BannerFallback,
}

/// Everything the tools could read off the image about its chip identity. The
/// `banner`, `vendor`, `model`, `rev`, and `marker_0x50` fields are display /
/// corroboration only — the `family` is decided by `Chip::from_digits`
/// over the `MTEKMT19xx` pattern (or the banner fallback).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChipInfo {
    /// The chip family (from the `MTEKMT19xx` string, or the banner fallback).
    pub family: Chip,
    /// How `family` was decided.
    pub confidence: Confidence,
    /// The trimmed boot-banner string (e.g. `"MT1959 Boot BU5"`). Display only.
    pub banner: String,
    /// Trimmed vendor field from the descriptor (e.g. `"HL-DT-ST"`). May be empty
    /// if the image has no well-formed identity page.
    pub vendor: String,
    /// Trimmed model field from the descriptor (e.g. `"BD-RE BU40N"`).
    pub model: String,
    /// Trimmed revision field from the descriptor (e.g. `"1.00"`).
    pub rev: String,
    /// Trimmed vendor-specific field from the descriptor (e.g. `"N003600"`) — the
    /// region/SKU code MakeMKV/LibreDrive key on. Empty if no identity page.
    pub vendor_specific: String,
    /// The matched `MTEKMT19xx` identity string, if any (display/audit).
    pub tag_string: Option<String>,
    /// The display-only marker byte at descriptor `+0x50`, if present. NEVER
    /// gates the family (see module docs).
    pub marker_0x50: Option<u8>,
    /// Whether the image carries a well-formed MTEK identity page at
    /// [`layout::DESCRIPTOR`] (tag `MTEKMT19..` present). Gates identity-page
    /// edits such as the downgrade-enable byte.
    pub descriptor_present: bool,
}

/// Trim NUL padding and surrounding whitespace from a lossily-decoded ASCII
/// field.
fn ascii_trim(bytes: &[u8]) -> String {
    String::from_utf8_lossy(bytes)
        .trim_matches(|c: char| c == '\0' || c.is_whitespace())
        .to_string()
}

/// Scan `image` for every distinct `MTEKMT19` + two-ASCII-digit occurrence,
/// returning the matched 10-byte strings (deduplicated, in first-seen order).
fn scan_mtek_tags(image: &[u8]) -> Vec<String> {
    const PREFIX: &[u8] = b"MTEKMT19";
    let mut seen: Vec<String> = Vec::new();
    if image.len() < PREFIX.len() + 2 {
        return seen;
    }
    let mut i = 0;
    while i + PREFIX.len() + 2 <= image.len() {
        if &image[i..i + PREFIX.len()] == PREFIX {
            let d0 = image[i + PREFIX.len()];
            let d1 = image[i + PREFIX.len() + 1];
            if d0.is_ascii_digit() && d1.is_ascii_digit() {
                let tag = String::from_utf8_lossy(&image[i..i + PREFIX.len() + 2]).into_owned();
                if !seen.contains(&tag) {
                    seen.push(tag);
                }
                i += PREFIX.len() + 2;
                continue;
            }
        }
        i += 1;
    }
    seen
}

/// Why [`detect_chip`] could not name a chip.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChipError {
    /// No `MTEKMT19xx` identity string and no MT19xx boot banner.
    Undetectable,
    /// The identity strings name only generations this crate does not handle.
    Unsupported(Vec<String>),
    /// The image names two different generations: corrupt or spliced.
    Conflicting(Vec<String>),
}

impl std::fmt::Display for ChipError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Undetectable => f.write_str(
                "undetectable — no MTEKMT19xx identity string and no MT19xx boot banner \
                 (unpack packed/truncated dumps first)",
            ),
            Self::Unsupported(tags) => write!(
                f,
                "image names an unsupported MT19xx variant ({}) — only MT1959 and MT1939 are handled",
                tags.join(", ")
            ),
            Self::Conflicting(tags) => write!(
                f,
                "image carries conflicting chip-family identity strings ({}) — corrupt or spliced; \
                 refusing to guess",
                tags.join(", ")
            ),
        }
    }
}

impl std::error::Error for ChipError {}

/// Detect the chip family + model/rev from `image`.
///
/// Family is decided by the authoritative `MTEKMT19xx` identity string
/// (pattern-searched), falling back to the boot banner only when no such string
/// exists. Fails **only** when the image is truly unidentifiable (no identity
/// string and no family banner) or carries two conflicting `MTEKMT19xx` families
/// (a corrupt image) — the caller then aborts cleanly instead of guessing.
pub fn detect_chip(image: &[u8]) -> Result<ChipInfo, ChipError> {
    // --- Family key: the MTEKMT19xx identity string (authoritative). ---
    let tags = scan_mtek_tags(image);
    let mut families: Vec<(Chip, String)> = Vec::new();
    for tag in &tags {
        let b = tag.as_bytes();
        if let Some(fam) = Chip::from_digits(b[b.len() - 2], b[b.len() - 1]) {
            if !families.iter().any(|(f, _)| *f == fam) {
                families.push((fam, tag.clone()));
            }
        }
    }
    if families.len() > 1 {
        return Err(ChipError::Conflicting(
            families.into_iter().map(|(_, t)| t).collect(),
        ));
    }

    // --- Banner (display; also the fallback family source). ---
    let banner = read_banner(image);
    let banner_family = banner
        .as_deref()
        .and_then(|b: &str| Chip::from_banner(b.as_bytes()));

    let (family, confidence, tag_string) = match families.first() {
        Some((fam, tag)) => (*fam, Confidence::TagString, Some(tag.clone())),
        None => match banner_family {
            Some(fam) => (fam, Confidence::BannerFallback, None),
            None => {
                if tags.is_empty() {
                    return Err(ChipError::Undetectable);
                }
                return Err(ChipError::Unsupported(tags));
            }
        },
    };

    // --- Descriptor parse (display-only + DE anchor). ---
    let (vendor, model, rev, vendor_specific, marker_0x50, descriptor_present) =
        parse_descriptor(image);

    Ok(ChipInfo {
        family,
        confidence,
        banner: banner.unwrap_or_default(),
        vendor,
        model,
        rev,
        vendor_specific,
        tag_string,
        marker_0x50,
        descriptor_present,
    })
}

/// Read + trim the boot banner at [`layout::BANNER`], or `None` if the image is
/// too small to contain it.
fn read_banner(image: &[u8]) -> Option<String> {
    if image.len() < BANNER_OFFSET + BANNER_LEN {
        return None;
    }
    let banner_bytes = &image[BANNER_OFFSET..BANNER_OFFSET + BANNER_LEN];
    let nul = banner_bytes
        .iter()
        .position(|&b| b == 0)
        .unwrap_or(banner_bytes.len());
    let s = ascii_trim(&banner_bytes[..nul]);
    if s.is_empty() {
        None
    } else {
        Some(s)
    }
}

/// Parse the display-only descriptor fields and detect a well-formed identity
/// page. Returns `(vendor, model, rev, vendor_specific, marker_0x50,
/// descriptor_present)`; all strings empty and `descriptor_present == false` when
/// no identity page exists.
///
/// Descriptor record layout (mirrors the drive's SCSI INQUIRY): vendor@0x00 (8),
/// model@0x08 (16), rev@0x18 (4), `vendor_specific`@0x1C (8) — the region/SKU code
/// MakeMKV/LibreDrive key on (e.g. `N003600`) — then a `UNIQUE<ts>` stamp and the
/// `MTEKMT19xx` tag @0x34.
fn parse_descriptor(image: &[u8]) -> (String, String, String, String, Option<u8>, bool) {
    if image.len() < DESCRIPTOR_OFFSET + DESCRIPTOR_LEN {
        return (
            String::new(),
            String::new(),
            String::new(),
            String::new(),
            None,
            false,
        );
    }
    let desc = &image[DESCRIPTOR_OFFSET..DESCRIPTOR_OFFSET + DESCRIPTOR_LEN];
    let tag_present = desc[DESCRIPTOR_TAG_OFF..].starts_with(b"MTEKMT19");
    let vendor = ascii_trim(&desc[0x00..0x08]);
    let model = ascii_trim(&desc[0x08..0x18]);
    let rev = ascii_trim(&desc[0x18..0x1C]);
    let vendor_specific = ascii_trim(&desc[0x1C..0x24]);
    // The marker byte lives at descriptor `+0x50`, PAST the 0x40-byte record, so
    // it is read from the image directly (with a bounds check), not the slice.
    let marker = image
        .get(DESCRIPTOR_OFFSET + DESCRIPTOR_MARKER_OFF)
        .copied();
    if tag_present {
        (vendor, model, rev, vendor_specific, marker, true)
    } else {
        // No identity page: still expose the marker byte for audit, but report
        // no descriptor and empty display fields (they'd be garbage).
        (
            String::new(),
            String::new(),
            String::new(),
            String::new(),
            marker,
            false,
        )
    }
}

#[cfg(test)]
#[path = "chip_tests.rs"]
mod tests;