Skip to main content

oxideav_aacs/
keydb.rs

1//! KEYDB.cfg parser — the de-facto community AACS key-material
2//! database file format described in
3//! `docs/container/aacs/keydb-cfg-format.md`.
4//!
5//! Two on-disk shapes are accepted:
6//!
7//! 1. **Per-disc legacy form** (`<DISC_ID>=V <VUK> | label`). See
8//!    [`KeyDbEntry`].
9//! 2. **`|`-leader record form** documented in
10//!    `docs/container/aacs/keydb-cfg-format.md`. Lines of the form
11//!
12//!    ```text
13//!    | <TYPE> | <FIELDS...> ; <comment>
14//!    ```
15//!
16//!    where `<TYPE>` is one of `DK`, `PK`, `HC`, `DC`, `VID`, `VUK`,
17//!    `MEK`, `TK`, `KCD`, `DISCID`. See the format-doc for the full
18//!    syntax of each record type.
19//!
20//! `;` introduces a comment to end-of-line. Empty lines are ignored.
21//!
22//! The implementation here was written from the format-doc at
23//! `docs/container/aacs/keydb-cfg-format.md` and the AACS LA Common
24//! Final 0.953 PDF only.
25
26use crate::error::AacsError;
27use crate::vuk::Vuk;
28use std::collections::BTreeMap;
29use std::path::Path;
30
31/// One parsed legacy `<DISC_ID>=V <VUK>` KEYDB.cfg entry.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct KeyDbEntry {
34    /// 20-byte (40-hex) BD-ROM disc ID.
35    pub disc_id: [u8; 20],
36    /// 16-byte Volume Unique Key.
37    pub vuk: Vuk,
38    /// Optional free-form label.
39    pub label: Option<String>,
40    /// Optional pre-unwrapped CPS Unit Title Keys, indexed by CPS
41    /// Unit number (1-based). Present when the source KEYDB.cfg
42    /// line was in the extended pipe-tokenised per-disc form with
43    /// `U | 1-0x<key> | 2-0x<key> | ...` tokens — lets the consumer
44    /// skip the VUK→title-key AES-ECB unwrap step entirely.
45    pub unit_keys: Vec<(u16, [u8; 16])>,
46}
47
48/// A `| DK |` Device Key record per AACS Common Final 0.953 §3.2.1
49/// (Subset-Difference tree) and the format doc's "DK" section.
50///
51/// Each Device Key issued by AACS LA to a player carries the
52/// `(device_key, device_node, key_uv, key_u_mask_shift)` tuple that
53/// pins the key into one node of the Subset-Difference tree.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct DeviceKeyRecord {
56    /// 16-byte AES-128 Device Key.
57    pub device_key: [u8; 16],
58    /// 2-byte node index in the Subset-Difference tree.
59    pub device_node: [u8; 2],
60    /// 4-byte UV value identifying the `(u, v)` coordinate.
61    pub key_uv: [u8; 4],
62    /// 1-byte `u`-mask bit-shift count.
63    pub key_u_mask_shift: u8,
64    /// Trailing free-form comment (typically the MKB version range
65    /// the key is valid for).
66    pub comment: Option<String>,
67}
68
69/// A `| PK |` Processing Key record. 16-byte AES-128 value, the output
70/// of running the Subset-Difference walk against a particular MKB, plus
71/// the trailing free-form comment that typically records the MKB
72/// version range the key applies to.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ProcessingKey {
75    /// 16-byte AES-128 Processing Key.
76    pub processing_key: [u8; 16],
77    /// Trailing free-form comment.
78    pub comment: Option<String>,
79}
80
81/// A `| HC |` Host Certificate + private key record per AACS Common
82/// Final 0.953 §A.1 / §A.3 and the format doc's "HC" section.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct HostCertRecord {
85    /// 20-byte ECDSA-secp160r1 private-key scalar.
86    pub host_priv_key: [u8; 20],
87    /// Variable-length host certificate. The standard layout is 92
88    /// bytes (= 0x005C); the parser keeps the raw bytes verbatim and
89    /// only validates that the embedded length field (offset 2,
90    /// big-endian u16) matches the buffer length.
91    pub host_cert: Vec<u8>,
92    /// Trailing free-form comment.
93    pub comment: Option<String>,
94}
95
96impl HostCertRecord {
97    /// Host ID — bytes 8..14 of the certificate (Common Final 0.953
98    /// §A.1 host certificate layout). Returns `None` if the buffer
99    /// is shorter than 14 bytes.
100    pub fn host_id(&self) -> Option<[u8; 6]> {
101        if self.host_cert.len() < 14 {
102            return None;
103        }
104        let mut out = [0u8; 6];
105        out.copy_from_slice(&self.host_cert[8..14]);
106        Some(out)
107    }
108
109    /// Certificate type byte — should be `0x02` for a host cert.
110    pub fn cert_type(&self) -> Option<u8> {
111        self.host_cert.first().copied()
112    }
113
114    /// Total certificate length encoded in the cert header at offset
115    /// 2..4 (big-endian).
116    pub fn declared_length(&self) -> Option<u16> {
117        if self.host_cert.len() < 4 {
118            return None;
119        }
120        Some(u16::from_be_bytes([self.host_cert[2], self.host_cert[3]]))
121    }
122}
123
124/// A `| DC |` Drive Certificate + private key record (drive side of
125/// the BD-AACS Drive-Host authentication).
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct DriveCertRecord {
128    /// 20-byte ECDSA-secp160r1 private-key scalar.
129    pub drive_priv_key: [u8; 20],
130    /// Variable-length drive certificate, raw bytes.
131    pub drive_cert: Vec<u8>,
132    /// Trailing free-form comment.
133    pub comment: Option<String>,
134}
135
136/// A per-disc record set scoped under a `| DISCID |` row. Holds any
137/// VID / VUK / MEK / TK / KCD rows that follow until the next
138/// `DISCID` row.
139#[derive(Debug, Clone, Default, PartialEq, Eq)]
140pub struct DiscRecords {
141    /// 20-byte disc identifier (from the `| DISCID |` row).
142    pub disc_id: [u8; 20],
143    /// 16-byte Volume ID — `| VID |`.
144    pub vid: Option<[u8; 16]>,
145    /// 16-byte Volume Unique Key — `| VUK |`.
146    pub vuk: Option<Vuk>,
147    /// 16-byte Media Encryption Key — `| MEK |`.
148    pub mek: Option<[u8; 16]>,
149    /// Title Keys — `| TK |`. Multiple rows are accumulated.
150    pub title_keys: Vec<[u8; 16]>,
151    /// Key Conversion Data — `| KCD |`. Raw bytes (variable length).
152    pub kcd: Option<Vec<u8>>,
153    /// Free-form label / comments associated with the `DISCID` row.
154    pub label: Option<String>,
155}
156
157/// In-memory KEYDB.cfg database.
158///
159/// Holds the legacy per-disc `<DISC_ID>=V <VUK>` entries plus the
160/// extended `|`-leader header records (DK / PK / HC / DC / per-disc
161/// VID/VUK/MEK/TK/KCD groups). Use the accessors to read each
162/// collection back.
163#[derive(Debug, Default, Clone)]
164pub struct KeyDb {
165    by_disc_id: BTreeMap<[u8; 20], KeyDbEntry>,
166    device_keys: Vec<DeviceKeyRecord>,
167    processing_keys: Vec<ProcessingKey>,
168    host_certs: Vec<HostCertRecord>,
169    drive_certs: Vec<DriveCertRecord>,
170    disc_records: BTreeMap<[u8; 20], DiscRecords>,
171}
172
173/// One non-empty, non-comment line that the parser couldn't make sense
174/// of.
175///
176/// Produced by [`KeyDb::parse_with_report`] alongside the parsed
177/// database so callers can surface a useful diagnostic — e.g. a Blu-ray
178/// ripping tool can list which `KEYDB.cfg` entries were rejected and
179/// why, instead of silently ignoring them on stderr.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct SkippedLine {
182    /// 1-based line number in the original input.
183    pub line_number: usize,
184    /// Best-effort excerpt of the offending line (already truncated to
185    /// at most 80 characters by the per-line parser).
186    pub snippet: String,
187    /// `Display`-formatted parse error returned for that line.
188    pub reason: String,
189}
190
191/// Outcome of a tolerant KEYDB.cfg parse.
192///
193/// Tracks every line we couldn't interpret as either a legacy
194/// `<DISC_ID>=V<VUK>` entry or a `|`-leader record. Empty when the
195/// whole file parsed cleanly. Each entry pairs a 1-based line number
196/// with a short excerpt and the `Display`-formatted [`AacsError`] the
197/// per-line parser returned, so a caller can present a useful "we
198/// loaded N records but skipped M lines for the following reasons"
199/// summary without having to re-run the parser.
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct ParseReport {
202    /// Lines the parser couldn't interpret, in source order.
203    pub skipped: Vec<SkippedLine>,
204}
205
206impl ParseReport {
207    /// `true` if every non-empty, non-comment line in the input parsed
208    /// cleanly.
209    pub fn is_clean(&self) -> bool {
210        self.skipped.is_empty()
211    }
212
213    /// Number of lines the parser couldn't interpret.
214    pub fn skipped_count(&self) -> usize {
215        self.skipped.len()
216    }
217}
218
219impl KeyDb {
220    /// Parse a KEYDB.cfg byte stream from a `&str`.
221    ///
222    /// Real-world KEYDB.cfg files mix many ad-hoc line forms — banner
223    /// comments, Processing Key records, custom export headers, etc.
224    /// The parser dispatches on the first non-whitespace character:
225    ///
226    /// - `|` → `|`-leader record (DK / PK / HC / DC / VID / VUK / MEK
227    ///   / TK / KCD / DISCID), parsed per
228    ///   `docs/container/aacs/keydb-cfg-format.md`.
229    /// - hex → legacy per-disc `<DISC_ID>=V <VUK> | label` line.
230    /// - anything else → skip with diagnostic.
231    ///
232    /// Lines we can't parse are skipped rather than failing the whole
233    /// load. Set `OXIDEAV_AACS_DEBUG=1` to surface each skip on
234    /// stderr. Callers that want a structured list of every skipped
235    /// line (line number, excerpt, parse error message) should use
236    /// [`KeyDb::parse_with_report`] instead.
237    pub fn parse(text: &str) -> Result<Self, AacsError> {
238        let (db, _report) = Self::parse_with_report(text)?;
239        Ok(db)
240    }
241
242    /// Parse a KEYDB.cfg byte stream and return both the populated
243    /// database and a [`ParseReport`] describing every non-empty,
244    /// non-comment line that the parser couldn't interpret.
245    ///
246    /// Same tolerance as [`KeyDb::parse`] (no line ever fails the
247    /// whole load), but every skipped line is captured in the
248    /// returned report so callers can surface a "loaded N records,
249    /// skipped M lines" diagnostic to the user. The report's
250    /// `skipped` vector preserves the order in which lines appeared
251    /// in the input.
252    ///
253    /// The `OXIDEAV_AACS_DEBUG=1` environment toggle still mirrors
254    /// each skip to stderr — the report makes the same information
255    /// available programmatically.
256    pub fn parse_with_report(text: &str) -> Result<(Self, ParseReport), AacsError> {
257        let debug = std::env::var_os("OXIDEAV_AACS_DEBUG").is_some();
258        let mut out = Self::default();
259        let mut report = ParseReport::default();
260        // `| DISCID |` sets the current disc-scope; subsequent VID /
261        // VUK / MEK / TK / KCD rows are attributed to it.
262        let mut current_discid: Option<[u8; 20]> = None;
263        // 1-based line numbering for human-friendly diagnostics — lines
264        // file editors / IDE jumps interpret the same way.
265        for (line_idx, raw) in text.lines().enumerate() {
266            let line_number = line_idx + 1;
267            // Split body / trailing-comment on the first `;`.
268            let (body, comment) = match raw.find(';') {
269                Some(i) => (&raw[..i], Some(raw[i + 1..].trim().to_string())),
270                None => (raw, None),
271            };
272            let body = body.trim();
273            if body.is_empty() {
274                continue;
275            }
276            let comment_owned = comment.filter(|s| !s.is_empty());
277            // Dispatch on first non-whitespace char.
278            let res = if body.starts_with('|') {
279                parse_pipe_record(
280                    body,
281                    comment_owned.as_deref(),
282                    &mut out,
283                    &mut current_discid,
284                )
285            } else {
286                parse_legacy_line(body).map(|entry| {
287                    out.by_disc_id.insert(entry.disc_id, entry);
288                })
289            };
290            if let Err(e) = res {
291                if debug {
292                    eprintln!("oxideav-aacs: KEYDB.cfg line {line_number} skipped — {e}");
293                }
294                report.skipped.push(SkippedLine {
295                    line_number,
296                    snippet: truncate_excerpt(body, 80),
297                    reason: e.to_string(),
298                });
299            }
300        }
301        if debug {
302            eprintln!(
303                "oxideav-aacs: KEYDB.cfg parse — kept {} per-disc + {} DK + {} PK + {} HC + {} DC + {} DISCID-scoped, skipped {} unparseable lines",
304                out.by_disc_id.len(),
305                out.device_keys.len(),
306                out.processing_keys.len(),
307                out.host_certs.len(),
308                out.drive_certs.len(),
309                out.disc_records.len(),
310                report.skipped.len()
311            );
312        }
313        Ok((out, report))
314    }
315
316    /// Load KEYDB.cfg from a filesystem path.
317    pub fn load_from(path: impl AsRef<Path>) -> Result<Self, AacsError> {
318        let text = std::fs::read_to_string(path.as_ref())?;
319        Self::parse(&text)
320    }
321
322    /// Load a KEYDB.cfg from a filesystem path, returning both the
323    /// parsed database and the [`ParseReport`] describing every line
324    /// that was skipped. Same I/O semantics as [`KeyDb::load_from`];
325    /// useful for diagnostics tooling that needs to surface why
326    /// particular entries were dropped.
327    pub fn load_from_with_report(path: impl AsRef<Path>) -> Result<(Self, ParseReport), AacsError> {
328        let text = std::fs::read_to_string(path.as_ref())?;
329        Self::parse_with_report(&text)
330    }
331
332    /// Load KEYDB.cfg from the default per-platform search path.
333    ///
334    /// Search order:
335    /// 1. `$OXIDEAV_AACS_KEYDB` if set.
336    /// 2. macOS only: `$HOME/Library/Preferences/aacs/KEYDB.cfg` —
337    ///    the conventional macOS user-defaults location for AACS
338    ///    key databases on Apple platforms.
339    /// 3. `$XDG_CONFIG_HOME/aacs/KEYDB.cfg`.
340    /// 4. Each entry in `$XDG_CONFIG_DIRS` (`:`-split) +
341    ///    `aacs/KEYDB.cfg`.
342    /// 5. `$HOME/.config/aacs/KEYDB.cfg`.
343    ///
344    /// Returns `Err(MissingDiscFile)` if no candidate exists.
345    pub fn load_default() -> Result<Self, AacsError> {
346        for path in default_search_paths() {
347            if path.exists() {
348                return Self::load_from(path);
349            }
350        }
351        Err(AacsError::MissingDiscFile("KEYDB.cfg"))
352    }
353
354    /// Look up a VUK by disc ID. Returns `None` if no entry matches.
355    ///
356    /// Checks the legacy `<DISC_ID>=V<VUK>` map first, then falls back
357    /// to any `| DISCID |` / `| VUK |` scoped record set carrying the
358    /// same disc ID.
359    pub fn vuk_for_disc(&self, disc_id: &[u8; 20]) -> Option<Vuk> {
360        if let Some(e) = self.by_disc_id.get(disc_id) {
361            return Some(e.vuk);
362        }
363        self.disc_records.get(disc_id).and_then(|r| r.vuk)
364    }
365
366    /// Look up the full parsed entry by disc ID.
367    pub fn entry_for_disc(&self, disc_id: &[u8; 20]) -> Option<&KeyDbEntry> {
368        self.by_disc_id.get(disc_id)
369    }
370
371    /// Iterate legacy `<DISC_ID>=V<VUK>` entries.
372    pub fn entries(&self) -> impl Iterator<Item = &KeyDbEntry> {
373        self.by_disc_id.values()
374    }
375
376    /// All parsed `| DK |` Device Key rows.
377    pub fn device_keys(&self) -> &[DeviceKeyRecord] {
378        &self.device_keys
379    }
380
381    /// All parsed `| PK |` Processing Key rows.
382    pub fn processing_keys(&self) -> &[ProcessingKey] {
383        &self.processing_keys
384    }
385
386    /// All parsed `| HC |` Host Certificate rows.
387    pub fn host_certs(&self) -> &[HostCertRecord] {
388        &self.host_certs
389    }
390
391    /// All parsed `| DC |` Drive Certificate rows.
392    pub fn drive_certs(&self) -> &[DriveCertRecord] {
393        &self.drive_certs
394    }
395
396    /// All `| DISCID |`-scoped record sets (VID / VUK / MEK / TK /
397    /// KCD), keyed by disc ID.
398    pub fn disc_records(&self) -> &BTreeMap<[u8; 20], DiscRecords> {
399        &self.disc_records
400    }
401
402    /// Look up a `| DISCID |`-scoped record set for the given disc.
403    pub fn disc_record(&self, disc_id: &[u8; 20]) -> Option<&DiscRecords> {
404        self.disc_records.get(disc_id)
405    }
406
407    /// Number of legacy entries (back-compat helper; new code should
408    /// usually use the more specific accessors).
409    pub fn len(&self) -> usize {
410        self.by_disc_id.len()
411    }
412
413    /// Whether the database is empty (legacy entries only).
414    pub fn is_empty(&self) -> bool {
415        self.by_disc_id.is_empty()
416            && self.device_keys.is_empty()
417            && self.processing_keys.is_empty()
418            && self.host_certs.is_empty()
419            && self.drive_certs.is_empty()
420            && self.disc_records.is_empty()
421    }
422}
423
424fn default_search_paths() -> Vec<std::path::PathBuf> {
425    use std::path::PathBuf;
426    let mut out = Vec::new();
427    if let Ok(p) = std::env::var("OXIDEAV_AACS_KEYDB") {
428        if !p.is_empty() {
429            out.push(PathBuf::from(p));
430        }
431    }
432    #[cfg(target_os = "macos")]
433    if let Ok(home) = std::env::var("HOME") {
434        if !home.is_empty() {
435            out.push(
436                PathBuf::from(&home)
437                    .join("Library")
438                    .join("Preferences")
439                    .join("aacs")
440                    .join("KEYDB.cfg"),
441            );
442        }
443    }
444    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
445        if !xdg.is_empty() {
446            out.push(PathBuf::from(xdg).join("aacs").join("KEYDB.cfg"));
447        }
448    }
449    if let Ok(dirs) = std::env::var("XDG_CONFIG_DIRS") {
450        for d in dirs.split(':') {
451            if !d.is_empty() {
452                out.push(PathBuf::from(d).join("aacs").join("KEYDB.cfg"));
453            }
454        }
455    }
456    if let Ok(home) = std::env::var("HOME") {
457        if !home.is_empty() {
458            out.push(
459                PathBuf::from(home)
460                    .join(".config")
461                    .join("aacs")
462                    .join("KEYDB.cfg"),
463            );
464        }
465    }
466    out
467}
468
469// ---- legacy `<DISC_ID>=V<VUK>` parser -----------------------------------
470
471/// Parse one legacy `<DISC_ID>=V<VUK>` line (also accepting the
472/// extended pipe-tokenised per-disc form). Returns `KeyDbParseError`
473/// on failure.
474fn parse_legacy_line(line: &str) -> Result<KeyDbEntry, AacsError> {
475    let (disc_id_text, rhs) = match line.split_once('=') {
476        Some(parts) => parts,
477        None => return Err(make_legacy_err(line)),
478    };
479    let disc_id_text = strip_hex_prefix(disc_id_text.trim());
480    let disc_id = parse_hex_array_20_legacy(disc_id_text)?;
481
482    let pipe_tokens: Vec<&str> = rhs.split('|').map(str::trim).collect();
483    let mut vuk_bytes: Option<[u8; 16]> = None;
484    let mut unit_keys: Vec<(u16, [u8; 16])> = Vec::new();
485    let mut label_parts: Vec<String> = Vec::new();
486    let mut current_flag: Option<char> = None;
487    for ptok in pipe_tokens {
488        if ptok.is_empty() {
489            continue;
490        }
491        fn is_flag_word(s: &str) -> bool {
492            s.len() == 1
493                && matches!(
494                    s.as_bytes()[0],
495                    b'D' | b'M' | b'I' | b'V' | b'U' | b'd' | b'm' | b'i' | b'v' | b'u'
496                )
497        }
498        let (head, value): (&str, &str) = if let Some(idx) = ptok.find(char::is_whitespace) {
499            let candidate = &ptok[..idx];
500            if is_flag_word(candidate) {
501                (candidate, ptok[idx..].trim())
502            } else {
503                ("", ptok)
504            }
505        } else if is_flag_word(ptok) {
506            (ptok, "")
507        } else {
508            ("", ptok)
509        };
510        if !head.is_empty() {
511            current_flag = head.chars().next().map(|c| c.to_ascii_uppercase());
512        }
513        if value.is_empty() {
514            continue;
515        }
516        match current_flag {
517            Some('V') => {
518                let raw = strip_hex_prefix(value);
519                if raw.len() == 32 {
520                    vuk_bytes = Some(parse_hex_array_16_legacy(raw)?);
521                }
522                current_flag = None;
523            }
524            Some('U') => {
525                if let Some((id_str, key_str)) = value.split_once('-') {
526                    let key_str = strip_hex_prefix(key_str.trim());
527                    if let (Ok(id), Ok(key)) = (
528                        id_str.trim().parse::<u16>(),
529                        parse_hex_array_16_legacy(key_str),
530                    ) {
531                        unit_keys.push((id, key));
532                    }
533                }
534            }
535            Some(_) => {
536                current_flag = None;
537            }
538            None => {
539                label_parts.push(value.to_string());
540            }
541        }
542    }
543
544    let vuk_bytes = vuk_bytes.ok_or_else(|| make_legacy_err(line))?;
545    let label = if label_parts.is_empty() {
546        None
547    } else {
548        Some(label_parts.join(" | "))
549    };
550
551    Ok(KeyDbEntry {
552        disc_id,
553        vuk: Vuk::from_bytes(vuk_bytes),
554        label,
555        unit_keys,
556    })
557}
558
559// ---- `|`-leader record parser -------------------------------------------
560
561/// Tokenise a `|`-leader record line into its non-empty `|`-segments.
562///
563/// A well-formed line per the format-doc starts and ends with `|`, so
564/// splitting on `|` yields a leading and trailing empty string we
565/// discard. Anything else is returned as a trimmed slice.
566fn pipe_tokenize(body: &str) -> Vec<&str> {
567    body.split('|').map(str::trim).collect()
568}
569
570/// Split a `|`-segment into `(NAME, VALUE)` per the format-doc §
571/// "Lexical syntax": `NAME 0xHEXVALUE` if a space exists and the part
572/// before it is non-empty + non-hex, else `("", VALUE)` for positional.
573fn split_named(field: &str) -> (&str, &str) {
574    if let Some(idx) = field.find(char::is_whitespace) {
575        let head = field[..idx].trim();
576        let rest = field[idx..].trim();
577        // Heuristic: if the head starts with `0x`, treat the whole
578        // thing as a positional value (defensive — names never start
579        // with `0x`).
580        if head.starts_with("0x") || head.starts_with("0X") {
581            ("", field.trim())
582        } else {
583            (head, rest)
584        }
585    } else {
586        ("", field.trim())
587    }
588}
589
590/// Parse a hex literal of exactly `expected_len` characters into an
591/// owned `Vec<u8>` of `expected_len / 2` bytes. The literal MUST be
592/// `0x`-prefixed per the format-doc.
593fn parse_hex_fixed(value: &str, expected_len: usize, field_name: &str) -> Result<Vec<u8>, String> {
594    let v = value.trim();
595    let stripped = v
596        .strip_prefix("0x")
597        .or_else(|| v.strip_prefix("0X"))
598        .ok_or_else(|| format!("{field_name}: missing 0x prefix"))?;
599    if stripped.len() != expected_len {
600        return Err(format!(
601            "{field_name}: expected {expected_len} hex chars, got {}",
602            stripped.len()
603        ));
604    }
605    parse_hex_bytes(stripped).ok_or_else(|| format!("{field_name}: non-hex character"))
606}
607
608/// Parse a `0x…` hex literal of *any* even length into an owned
609/// `Vec<u8>`. Used for variable-length fields (`HOST_CERT`,
610/// `DRIVE_CERT`, `KCD`).
611fn parse_hex_var(value: &str, field_name: &str) -> Result<Vec<u8>, String> {
612    let v = value.trim();
613    let stripped = v
614        .strip_prefix("0x")
615        .or_else(|| v.strip_prefix("0X"))
616        .ok_or_else(|| format!("{field_name}: missing 0x prefix"))?;
617    if stripped.is_empty() || stripped.len() % 2 != 0 {
618        return Err(format!(
619            "{field_name}: hex length must be a positive even number, got {}",
620            stripped.len()
621        ));
622    }
623    parse_hex_bytes(stripped).ok_or_else(|| format!("{field_name}: non-hex character"))
624}
625
626fn parse_hex_bytes(hex: &str) -> Option<Vec<u8>> {
627    if hex.len() % 2 != 0 {
628        return None;
629    }
630    let mut out = Vec::with_capacity(hex.len() / 2);
631    let b = hex.as_bytes();
632    let mut i = 0;
633    while i < b.len() {
634        let hi = hex_digit(b[i])?;
635        let lo = hex_digit(b[i + 1])?;
636        out.push((hi << 4) | lo);
637        i += 2;
638    }
639    Some(out)
640}
641
642fn hex_digit(b: u8) -> Option<u8> {
643    match b {
644        b'0'..=b'9' => Some(b - b'0'),
645        b'a'..=b'f' => Some(b - b'a' + 10),
646        b'A'..=b'F' => Some(b - b'A' + 10),
647        _ => None,
648    }
649}
650
651/// Look up a named field in a `|`-tokenised record. Per the format-doc
652/// the parser accepts both `NAME 0xVALUE` (named) and bare `0xVALUE`
653/// (positional). Named fields may appear in any order. This helper
654/// returns the value associated with `name` (case-insensitive) if
655/// present.
656fn find_named<'a>(tokens: &'a [&'a str], name: &str) -> Option<&'a str> {
657    for tok in tokens {
658        let (n, v) = split_named(tok);
659        if !n.is_empty() && n.eq_ignore_ascii_case(name) {
660            return Some(v);
661        }
662    }
663    None
664}
665
666/// Return all bare-positional values (no `NAME` prefix) from a
667/// `|`-tokenised record, skipping the leader.
668fn positional_values<'a>(tokens: &'a [&'a str]) -> Vec<&'a str> {
669    tokens
670        .iter()
671        .filter_map(|tok| {
672            let (n, v) = split_named(tok);
673            if n.is_empty() && !v.is_empty() {
674                Some(v)
675            } else {
676                None
677            }
678        })
679        .collect()
680}
681
682/// Parse a `|`-leader record. Mutates `db` and `current_discid` in
683/// place. Returns `Err(AacsError::HeaderParseError)` on a malformed
684/// record.
685fn parse_pipe_record(
686    body: &str,
687    comment: Option<&str>,
688    db: &mut KeyDb,
689    current_discid: &mut Option<[u8; 20]>,
690) -> Result<(), AacsError> {
691    let tokens_full = pipe_tokenize(body);
692    // Format-doc: outer `|` is mandatory at line start. Splitting on
693    // `|` therefore produces an empty leading token. A trailing `|`
694    // is also tolerated; if the user wrote `| FOO | BAR` without one
695    // we still parse it. We require the FIRST non-empty token to be
696    // the leader.
697    if tokens_full.first().copied() != Some("") {
698        return Err(header_err(
699            body,
700            "line must start with `|` (no leader `|` found)",
701        ));
702    }
703    // Drop leading/trailing empties.
704    let mut tokens: Vec<&str> = tokens_full
705        .iter()
706        .filter(|t| !t.is_empty())
707        .copied()
708        .collect();
709    if tokens.is_empty() {
710        return Err(header_err(body, "empty record"));
711    }
712    let leader = tokens.remove(0);
713    // Field tokens passed to the per-record handlers.
714    let fields: Vec<&str> = tokens;
715
716    match leader.to_ascii_uppercase().as_str() {
717        "DK" => parse_dk(&fields, comment, body, db),
718        "PK" => parse_pk(&fields, comment, body, db),
719        "HC" => parse_hc(&fields, comment, body, db),
720        "DC" => parse_dc(&fields, comment, body, db),
721        "DISCID" => parse_discid(&fields, comment, body, db, current_discid),
722        "VID" => parse_vid(&fields, body, db, current_discid),
723        "VUK" => parse_vuk(&fields, body, db, current_discid),
724        "MEK" => parse_mek(&fields, body, db, current_discid),
725        "TK" => parse_tk(&fields, body, db, current_discid),
726        "KCD" => parse_kcd(&fields, body, db, current_discid),
727        other => Err(header_err(
728            body,
729            &format!("unrecognised record leader `{other}`"),
730        )),
731    }
732}
733
734fn parse_dk(
735    fields: &[&str],
736    comment: Option<&str>,
737    body: &str,
738    db: &mut KeyDb,
739) -> Result<(), AacsError> {
740    let dk_hex = find_named(fields, "DEVICE_KEY")
741        .ok_or_else(|| header_err(body, "DK: missing DEVICE_KEY field"))?;
742    let node_hex = find_named(fields, "DEVICE_NODE")
743        .ok_or_else(|| header_err(body, "DK: missing DEVICE_NODE field"))?;
744    let uv_hex =
745        find_named(fields, "KEY_UV").ok_or_else(|| header_err(body, "DK: missing KEY_UV field"))?;
746    let shift_hex = find_named(fields, "KEY_U_MASK_SHIFT")
747        .ok_or_else(|| header_err(body, "DK: missing KEY_U_MASK_SHIFT field"))?;
748
749    let dk = parse_hex_fixed(dk_hex, 32, "DEVICE_KEY").map_err(|m| header_err(body, &m))?;
750    let node = parse_hex_fixed(node_hex, 4, "DEVICE_NODE").map_err(|m| header_err(body, &m))?;
751    let uv = parse_hex_fixed(uv_hex, 8, "KEY_UV").map_err(|m| header_err(body, &m))?;
752    let shift =
753        parse_hex_fixed(shift_hex, 2, "KEY_U_MASK_SHIFT").map_err(|m| header_err(body, &m))?;
754
755    let mut device_key = [0u8; 16];
756    device_key.copy_from_slice(&dk);
757    let mut device_node = [0u8; 2];
758    device_node.copy_from_slice(&node);
759    let mut key_uv = [0u8; 4];
760    key_uv.copy_from_slice(&uv);
761
762    db.device_keys.push(DeviceKeyRecord {
763        device_key,
764        device_node,
765        key_uv,
766        key_u_mask_shift: shift[0],
767        comment: comment.map(str::to_string),
768    });
769    Ok(())
770}
771
772fn parse_pk(
773    fields: &[&str],
774    comment: Option<&str>,
775    body: &str,
776    db: &mut KeyDb,
777) -> Result<(), AacsError> {
778    // PK is purely positional: `| PK | 0x<32 hex chars>`.
779    let positionals = positional_values(fields);
780    if positionals.len() != 1 {
781        return Err(header_err(
782            body,
783            &format!(
784                "PK: expected exactly 1 positional value, got {}",
785                positionals.len()
786            ),
787        ));
788    }
789    let pk =
790        parse_hex_fixed(positionals[0], 32, "PROCESSING_KEY").map_err(|m| header_err(body, &m))?;
791    let mut processing_key = [0u8; 16];
792    processing_key.copy_from_slice(&pk);
793    db.processing_keys.push(ProcessingKey {
794        processing_key,
795        comment: comment.map(str::to_string),
796    });
797    Ok(())
798}
799
800fn parse_hc(
801    fields: &[&str],
802    comment: Option<&str>,
803    body: &str,
804    db: &mut KeyDb,
805) -> Result<(), AacsError> {
806    let priv_hex = find_named(fields, "HOST_PRIV_KEY")
807        .ok_or_else(|| header_err(body, "HC: missing HOST_PRIV_KEY field"))?;
808    let cert_hex = find_named(fields, "HOST_CERT")
809        .ok_or_else(|| header_err(body, "HC: missing HOST_CERT field"))?;
810
811    let priv_bytes =
812        parse_hex_fixed(priv_hex, 40, "HOST_PRIV_KEY").map_err(|m| header_err(body, &m))?;
813    let cert_bytes = parse_hex_var(cert_hex, "HOST_CERT").map_err(|m| header_err(body, &m))?;
814
815    // Common Final 0.953 §A.1: host cert layout has a u16 length at
816    // offset 2..4 (big-endian) equal to the total cert length. Be
817    // tolerant if the buffer is too short to even check.
818    if cert_bytes.len() >= 4 {
819        let declared = u16::from_be_bytes([cert_bytes[2], cert_bytes[3]]) as usize;
820        if declared != cert_bytes.len() {
821            return Err(header_err(
822                body,
823                &format!(
824                    "HC: HOST_CERT internal length field {declared} != buffer length {}",
825                    cert_bytes.len()
826                ),
827            ));
828        }
829    }
830
831    let mut host_priv_key = [0u8; 20];
832    host_priv_key.copy_from_slice(&priv_bytes);
833    db.host_certs.push(HostCertRecord {
834        host_priv_key,
835        host_cert: cert_bytes,
836        comment: comment.map(str::to_string),
837    });
838    Ok(())
839}
840
841fn parse_dc(
842    fields: &[&str],
843    comment: Option<&str>,
844    body: &str,
845    db: &mut KeyDb,
846) -> Result<(), AacsError> {
847    let priv_hex = find_named(fields, "DRIVE_PRIV_KEY")
848        .ok_or_else(|| header_err(body, "DC: missing DRIVE_PRIV_KEY field"))?;
849    let cert_hex = find_named(fields, "DRIVE_CERT")
850        .ok_or_else(|| header_err(body, "DC: missing DRIVE_CERT field"))?;
851
852    let priv_bytes =
853        parse_hex_fixed(priv_hex, 40, "DRIVE_PRIV_KEY").map_err(|m| header_err(body, &m))?;
854    let cert_bytes = parse_hex_var(cert_hex, "DRIVE_CERT").map_err(|m| header_err(body, &m))?;
855
856    let mut drive_priv_key = [0u8; 20];
857    drive_priv_key.copy_from_slice(&priv_bytes);
858    db.drive_certs.push(DriveCertRecord {
859        drive_priv_key,
860        drive_cert: cert_bytes,
861        comment: comment.map(str::to_string),
862    });
863    Ok(())
864}
865
866fn parse_discid(
867    fields: &[&str],
868    comment: Option<&str>,
869    body: &str,
870    db: &mut KeyDb,
871    current_discid: &mut Option<[u8; 20]>,
872) -> Result<(), AacsError> {
873    // DISCID is positional: `| DISCID | 0x<40 hex chars> [| label]`.
874    let positionals = positional_values(fields);
875    if positionals.is_empty() {
876        return Err(header_err(body, "DISCID: missing disc-id positional value"));
877    }
878    let id_bytes =
879        parse_hex_fixed(positionals[0], 40, "DISCID").map_err(|m| header_err(body, &m))?;
880    let mut disc_id = [0u8; 20];
881    disc_id.copy_from_slice(&id_bytes);
882    *current_discid = Some(disc_id);
883
884    // Free-form trailing positional tokens become the label.
885    let label = if positionals.len() > 1 {
886        Some(positionals[1..].join(" | "))
887    } else {
888        None
889    };
890
891    let rec = db
892        .disc_records
893        .entry(disc_id)
894        .or_insert_with(|| DiscRecords {
895            disc_id,
896            ..DiscRecords::default()
897        });
898    if rec.label.is_none() {
899        rec.label = label;
900    }
901    if rec.label.is_none() {
902        rec.label = comment.map(str::to_string);
903    }
904    Ok(())
905}
906
907fn require_discid(
908    current_discid: &Option<[u8; 20]>,
909    leader: &str,
910    body: &str,
911) -> Result<[u8; 20], AacsError> {
912    current_discid.ok_or_else(|| {
913        header_err(
914            body,
915            &format!("{leader}: must be preceded by a `| DISCID |` row"),
916        )
917    })
918}
919
920fn parse_vid(
921    fields: &[&str],
922    body: &str,
923    db: &mut KeyDb,
924    current_discid: &Option<[u8; 20]>,
925) -> Result<(), AacsError> {
926    let did = require_discid(current_discid, "VID", body)?;
927    let value = positional_or_named(fields, "VID", body)?;
928    let bytes = parse_hex_fixed(value, 32, "VID").map_err(|m| header_err(body, &m))?;
929    let mut vid = [0u8; 16];
930    vid.copy_from_slice(&bytes);
931    let rec = db.disc_records.entry(did).or_insert_with(|| DiscRecords {
932        disc_id: did,
933        ..DiscRecords::default()
934    });
935    rec.vid = Some(vid);
936    Ok(())
937}
938
939fn parse_vuk(
940    fields: &[&str],
941    body: &str,
942    db: &mut KeyDb,
943    current_discid: &Option<[u8; 20]>,
944) -> Result<(), AacsError> {
945    let did = require_discid(current_discid, "VUK", body)?;
946    let value = positional_or_named(fields, "VUK", body)?;
947    let bytes = parse_hex_fixed(value, 32, "VUK").map_err(|m| header_err(body, &m))?;
948    let mut v = [0u8; 16];
949    v.copy_from_slice(&bytes);
950    let rec = db.disc_records.entry(did).or_insert_with(|| DiscRecords {
951        disc_id: did,
952        ..DiscRecords::default()
953    });
954    rec.vuk = Some(Vuk::from_bytes(v));
955    Ok(())
956}
957
958fn parse_mek(
959    fields: &[&str],
960    body: &str,
961    db: &mut KeyDb,
962    current_discid: &Option<[u8; 20]>,
963) -> Result<(), AacsError> {
964    let did = require_discid(current_discid, "MEK", body)?;
965    let value = positional_or_named(fields, "MEK", body)?;
966    let bytes = parse_hex_fixed(value, 32, "MEK").map_err(|m| header_err(body, &m))?;
967    let mut mek = [0u8; 16];
968    mek.copy_from_slice(&bytes);
969    let rec = db.disc_records.entry(did).or_insert_with(|| DiscRecords {
970        disc_id: did,
971        ..DiscRecords::default()
972    });
973    rec.mek = Some(mek);
974    Ok(())
975}
976
977fn parse_tk(
978    fields: &[&str],
979    body: &str,
980    db: &mut KeyDb,
981    current_discid: &Option<[u8; 20]>,
982) -> Result<(), AacsError> {
983    let did = require_discid(current_discid, "TK", body)?;
984    let value = positional_or_named(fields, "TK", body)?;
985    let bytes = parse_hex_fixed(value, 32, "TK").map_err(|m| header_err(body, &m))?;
986    let mut tk = [0u8; 16];
987    tk.copy_from_slice(&bytes);
988    let rec = db.disc_records.entry(did).or_insert_with(|| DiscRecords {
989        disc_id: did,
990        ..DiscRecords::default()
991    });
992    rec.title_keys.push(tk);
993    Ok(())
994}
995
996fn parse_kcd(
997    fields: &[&str],
998    body: &str,
999    db: &mut KeyDb,
1000    current_discid: &Option<[u8; 20]>,
1001) -> Result<(), AacsError> {
1002    let did = require_discid(current_discid, "KCD", body)?;
1003    let value = positional_or_named(fields, "KCD", body)?;
1004    let bytes = parse_hex_var(value, "KCD").map_err(|m| header_err(body, &m))?;
1005    let rec = db.disc_records.entry(did).or_insert_with(|| DiscRecords {
1006        disc_id: did,
1007        ..DiscRecords::default()
1008    });
1009    rec.kcd = Some(bytes);
1010    Ok(())
1011}
1012
1013/// Resolve the single hex value of a record that accepts both
1014/// `| NAME 0xVALUE |` and `| NAME | 0xVALUE |` shapes. The
1015/// format-doc table for "other record types" writes both styles
1016/// interchangeably (e.g. `VUK 0x<32 hex chars>` as a single token,
1017/// versus `| VID | 0x<32 hex chars> |` as two). Accept either.
1018fn positional_or_named<'a>(
1019    fields: &'a [&'a str],
1020    name: &str,
1021    body: &str,
1022) -> Result<&'a str, AacsError> {
1023    if let Some(v) = find_named(fields, name) {
1024        return Ok(v);
1025    }
1026    let positionals = positional_values(fields);
1027    if positionals.len() == 1 {
1028        return Ok(positionals[0]);
1029    }
1030    Err(header_err(
1031        body,
1032        &format!(
1033            "{name}: expected `{name} 0xVALUE` or `0xVALUE`, got {} positionals + no named match",
1034            positionals.len()
1035        ),
1036    ))
1037}
1038
1039// ---- shared helpers ------------------------------------------------------
1040
1041fn strip_hex_prefix(s: &str) -> &str {
1042    s.strip_prefix("0x")
1043        .or_else(|| s.strip_prefix("0X"))
1044        .unwrap_or(s)
1045}
1046
1047fn parse_hex_array_20_legacy(text: &str) -> Result<[u8; 20], AacsError> {
1048    if text.len() != 40 {
1049        return Err(make_legacy_err(text));
1050    }
1051    let mut out = [0u8; 20];
1052    for (i, byte) in out.iter_mut().enumerate() {
1053        let pair = &text[i * 2..i * 2 + 2];
1054        *byte = u8::from_str_radix(pair, 16).map_err(|_| make_legacy_err(text))?;
1055    }
1056    Ok(out)
1057}
1058
1059fn parse_hex_array_16_legacy(text: &str) -> Result<[u8; 16], AacsError> {
1060    if text.len() != 32 {
1061        return Err(make_legacy_err(text));
1062    }
1063    let mut out = [0u8; 16];
1064    for (i, byte) in out.iter_mut().enumerate() {
1065        let pair = &text[i * 2..i * 2 + 2];
1066        *byte = u8::from_str_radix(pair, 16).map_err(|_| make_legacy_err(text))?;
1067    }
1068    Ok(out)
1069}
1070
1071fn make_legacy_err(snippet: &str) -> AacsError {
1072    AacsError::KeyDbParseError(truncate_excerpt(snippet, 80))
1073}
1074
1075/// Truncate a snippet to at most `max_bytes` bytes without splitting a
1076/// UTF-8 codepoint. Used wherever the parser surfaces a best-effort
1077/// excerpt of the offending line in a diagnostic.
1078fn truncate_excerpt(snippet: &str, max_bytes: usize) -> String {
1079    if snippet.len() <= max_bytes {
1080        return snippet.to_string();
1081    }
1082    let cut = snippet
1083        .char_indices()
1084        .take_while(|(i, c)| i + c.len_utf8() <= max_bytes)
1085        .last()
1086        .map(|(i, c)| i + c.len_utf8())
1087        .unwrap_or(0);
1088    snippet[..cut].to_string()
1089}
1090
1091fn header_err(snippet: &str, msg: &str) -> AacsError {
1092    let excerpt = truncate_excerpt(snippet, 80);
1093    AacsError::HeaderParseError(format!("{msg} (near {excerpt:?})"))
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099
1100    // ---- legacy form back-compat tests ----------------------------------
1101
1102    #[test]
1103    fn parses_canonical_line() {
1104        let text = "0123456789ABCDEF0123456789ABCDEF01234567 = V 0102030405060708090A0B0C0D0E0F10 | Test Disc";
1105        let db = KeyDb::parse(text).unwrap();
1106        assert_eq!(db.len(), 1);
1107        let id = parse_hex_array_20_legacy("0123456789ABCDEF0123456789ABCDEF01234567").unwrap();
1108        let entry = db.entry_for_disc(&id).unwrap();
1109        assert_eq!(entry.label.as_deref(), Some("Test Disc"));
1110        assert_eq!(entry.vuk.as_bytes()[0], 0x01);
1111    }
1112
1113    #[test]
1114    fn parses_lowercase_hex() {
1115        let text = "abcdef0123456789abcdef0123456789abcdef01 = v fedcba9876543210fedcba9876543210";
1116        let db = KeyDb::parse(text).unwrap();
1117        assert_eq!(db.len(), 1);
1118        let id = parse_hex_array_20_legacy("ABCDEF0123456789ABCDEF0123456789ABCDEF01").unwrap();
1119        assert!(db.entry_for_disc(&id).is_some());
1120    }
1121
1122    #[test]
1123    fn ignores_blank_lines_and_comments() {
1124        let text = r#"
1125; this is a comment
1126;another comment
1127
11280123456789ABCDEF0123456789ABCDEF01234567 = V 0102030405060708090A0B0C0D0E0F10 ; trailing comment
1129"#;
1130        let db = KeyDb::parse(text).unwrap();
1131        assert_eq!(db.len(), 1);
1132    }
1133
1134    /// Real-world KEYDB.cfg files mix free-form banner lines,
1135    /// Processing Key records, and exporter-specific metadata that
1136    /// don't match our `<id> = V <vuk>` shape. We skip those rather
1137    /// than failing the whole load on the first bad line.
1138    #[test]
1139    fn skips_malformed_lines_without_failing_the_load() {
1140        let text = r#"
1141; banner
114200 = V 0102030405060708090A0B0C0D0E0F10
11430123456789ABCDEF0123456789ABCDEF01234567 = X 0102030405060708090A0B0C0D0E0F10
11440123456789ABCDEF0123456789ABCDEF01234567 = V 0102
11450123456789ABCDEF0123456789ABCDEF01234567 = V CAFEBABE0102030405060708090A0B0C | OK
1146"#;
1147        let db = KeyDb::parse(text).unwrap();
1148        assert_eq!(db.len(), 1);
1149        let id = parse_hex_array_20_legacy("0123456789ABCDEF0123456789ABCDEF01234567").unwrap();
1150        let entry = db.entry_for_disc(&id).unwrap();
1151        assert_eq!(entry.vuk.as_bytes()[0], 0xCA);
1152        assert_eq!(entry.label.as_deref(), Some("OK"));
1153    }
1154
1155    /// Extended pipe-tokenised per-disc form: `0x`-prefixed disc-id +
1156    /// pipe-tokenised single-char flags (D/M/I/V/U) introducing each
1157    /// value, plus `<id>-0x<hex>` Unit Keys after `U`.
1158    #[test]
1159    fn parses_extended_pipe_tokenised_per_disc_form() {
1160        let text = "0x0123456789ABCDEF0123456789ABCDEF01234567 = Test Title \
1161                    | D | 2017-10-12 \
1162                    | M | 0x6D6284E100C23949F40559732EA541CE \
1163                    | I | 0x3E91BD640F849EA14131E70B818A5182 \
1164                    | V | 0xD8C278536EE614B877FCF3E4DD631091 \
1165                    | U | 1-0xC8702051C53A11F873EF5851737E6B75 \
1166                    ; trailing comment";
1167        let db = KeyDb::parse(text).unwrap();
1168        assert_eq!(db.len(), 1);
1169        let id = parse_hex_array_20_legacy("0123456789ABCDEF0123456789ABCDEF01234567").unwrap();
1170        let entry = db.entry_for_disc(&id).unwrap();
1171        assert_eq!(entry.vuk.as_bytes()[0], 0xD8);
1172        assert_eq!(entry.vuk.as_bytes()[15], 0x91);
1173        assert_eq!(entry.unit_keys.len(), 1);
1174        assert_eq!(entry.unit_keys[0].0, 1);
1175        assert_eq!(entry.unit_keys[0].1[0], 0xC8);
1176        assert_eq!(entry.unit_keys[0].1[15], 0x75);
1177        assert_eq!(entry.label.as_deref(), Some("Test Title"));
1178    }
1179
1180    #[test]
1181    fn parses_extended_with_multiple_unit_keys() {
1182        let text = "0x0123456789ABCDEF0123456789ABCDEF01234567 = X \
1183                    | V | 0x0102030405060708090A0B0C0D0E0F10 \
1184                    | U | 1-0x11111111111111111111111111111111 \
1185                    | 2-0x22222222222222222222222222222222 \
1186                    | 3-0x33333333333333333333333333333333";
1187        let db = KeyDb::parse(text).unwrap();
1188        let id = parse_hex_array_20_legacy("0123456789ABCDEF0123456789ABCDEF01234567").unwrap();
1189        let entry = db.entry_for_disc(&id).unwrap();
1190        assert_eq!(entry.unit_keys.len(), 3);
1191        assert_eq!(entry.unit_keys[0], (1, [0x11; 16]));
1192        assert_eq!(entry.unit_keys[1], (2, [0x22; 16]));
1193        assert_eq!(entry.unit_keys[2], (3, [0x33; 16]));
1194    }
1195
1196    #[cfg(target_os = "macos")]
1197    #[test]
1198    fn macos_library_preferences_is_in_search_path() {
1199        let saved_home = std::env::var_os("HOME");
1200        std::env::set_var("HOME", "/Users/oxideav-test");
1201        let saved_env = std::env::var_os("OXIDEAV_AACS_KEYDB");
1202        std::env::remove_var("OXIDEAV_AACS_KEYDB");
1203
1204        let paths = default_search_paths();
1205        let want =
1206            std::path::PathBuf::from("/Users/oxideav-test/Library/Preferences/aacs/KEYDB.cfg");
1207        assert!(
1208            paths.contains(&want),
1209            "macOS search path missing Library/Preferences entry: {paths:?}",
1210        );
1211
1212        match saved_home {
1213            Some(v) => std::env::set_var("HOME", v),
1214            None => std::env::remove_var("HOME"),
1215        }
1216        if let Some(v) = saved_env {
1217            std::env::set_var("OXIDEAV_AACS_KEYDB", v);
1218        }
1219    }
1220
1221    // ---- `|`-leader record success tests --------------------------------
1222
1223    /// `| DK |` Device Key, all four named fields.
1224    #[test]
1225    fn parses_dk_record() {
1226        let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F \
1227                    | DEVICE_NODE 0x0800 \
1228                    | KEY_UV 0x00000400 \
1229                    | KEY_U_MASK_SHIFT 0x17 \
1230                    ; MKBv01-MKBv48";
1231        let db = KeyDb::parse(line).unwrap();
1232        assert_eq!(db.device_keys().len(), 1);
1233        let dk = &db.device_keys()[0];
1234        assert_eq!(
1235            dk.device_key,
1236            [
1237                0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
1238                0x0E, 0x0F,
1239            ]
1240        );
1241        assert_eq!(dk.device_node, [0x08, 0x00]);
1242        assert_eq!(dk.key_uv, [0x00, 0x00, 0x04, 0x00]);
1243        assert_eq!(dk.key_u_mask_shift, 0x17);
1244        assert_eq!(dk.comment.as_deref(), Some("MKBv01-MKBv48"));
1245    }
1246
1247    /// `| PK |` Processing Key.
1248    #[test]
1249    fn parses_pk_record() {
1250        let line = "| PK | 0xAABBCCDDEEFF00112233445566778899 ; MKBv12";
1251        let db = KeyDb::parse(line).unwrap();
1252        assert_eq!(db.processing_keys().len(), 1);
1253        let pk = &db.processing_keys()[0];
1254        assert_eq!(pk.processing_key[0], 0xAA);
1255        assert_eq!(pk.processing_key[15], 0x99);
1256        assert_eq!(pk.comment.as_deref(), Some("MKBv12"));
1257    }
1258
1259    /// `| HC |` Host Cert + private key. Cert is exactly 92 bytes with
1260    /// the cert-type=0x02 + version=0x03 + length=0x005C header.
1261    #[test]
1262    fn parses_hc_record() {
1263        // 92-byte cert: type=0x02, ver=0x03, length=0x005C BE, then
1264        // arbitrary payload bytes 0x04..0x5B.
1265        let mut cert = vec![0x02u8, 0x03, 0x00, 0x5C];
1266        for i in 4..92u8 {
1267            cert.push(i);
1268        }
1269        assert_eq!(cert.len(), 92);
1270        let cert_hex: String = cert.iter().map(|b| format!("{b:02X}")).collect();
1271        // 20-byte priv key.
1272        let priv_hex = "0102030405060708090A0B0C0D0E0F1011121314";
1273        let line = format!("| HC | HOST_PRIV_KEY 0x{priv_hex} | HOST_CERT 0x{cert_hex} ; valid");
1274        let db = KeyDb::parse(&line).unwrap();
1275        assert_eq!(db.host_certs().len(), 1);
1276        let hc = &db.host_certs()[0];
1277        assert_eq!(hc.host_priv_key[0], 0x01);
1278        assert_eq!(hc.host_priv_key[19], 0x14);
1279        assert_eq!(hc.host_cert.len(), 92);
1280        assert_eq!(hc.cert_type(), Some(0x02));
1281        assert_eq!(hc.declared_length(), Some(92));
1282        // Host ID at offset 8..14.
1283        assert_eq!(hc.host_id(), Some([8, 9, 10, 11, 12, 13]));
1284        assert_eq!(hc.comment.as_deref(), Some("valid"));
1285    }
1286
1287    /// `| DC |` Drive Cert + private key.
1288    #[test]
1289    fn parses_dc_record() {
1290        let priv_hex = "1112131415161718191A1B1C1D1E1F2021222324";
1291        let cert_hex = "DEADBEEFCAFEBABE";
1292        let line = format!("| DC | DRIVE_PRIV_KEY 0x{priv_hex} | DRIVE_CERT 0x{cert_hex}");
1293        let db = KeyDb::parse(&line).unwrap();
1294        assert_eq!(db.drive_certs().len(), 1);
1295        let dc = &db.drive_certs()[0];
1296        assert_eq!(dc.drive_priv_key[0], 0x11);
1297        assert_eq!(
1298            dc.drive_cert,
1299            vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]
1300        );
1301    }
1302
1303    /// `| DISCID |` + `| VID |` + `| VUK |` + `| MEK |` + `| TK |` scoped
1304    /// records all attach to the same disc.
1305    #[test]
1306    fn parses_disc_scoped_records() {
1307        let text = "\
1308| DISCID | 0x0123456789ABCDEF0123456789ABCDEF01234567 \n\
1309| VID | 0xAABBCCDDEEFF00112233445566778899 \n\
1310| VUK | 0xD8C278536EE614B877FCF3E4DD631091 \n\
1311| MEK | 0x11111111111111111111111111111111 \n\
1312| TK | 0x22222222222222222222222222222222 \n\
1313| TK | 0x33333333333333333333333333333333 \n\
1314";
1315        let db = KeyDb::parse(text).unwrap();
1316        let id = parse_hex_array_20_legacy("0123456789ABCDEF0123456789ABCDEF01234567").unwrap();
1317        let rec = db.disc_record(&id).unwrap();
1318        assert_eq!(rec.disc_id, id);
1319        assert_eq!(rec.vid.unwrap()[0], 0xAA);
1320        assert_eq!(rec.vuk.unwrap().as_bytes()[15], 0x91);
1321        assert_eq!(rec.mek.unwrap(), [0x11; 16]);
1322        assert_eq!(rec.title_keys.len(), 2);
1323        assert_eq!(rec.title_keys[0], [0x22; 16]);
1324        assert_eq!(rec.title_keys[1], [0x33; 16]);
1325
1326        // vuk_for_disc looks through both maps.
1327        assert_eq!(db.vuk_for_disc(&id).unwrap().as_bytes()[0], 0xD8);
1328    }
1329
1330    /// `| KCD |` Key Conversion Data — variable-length hex.
1331    #[test]
1332    fn parses_kcd_record() {
1333        let text = "\
1334| DISCID | 0x0123456789ABCDEF0123456789ABCDEF01234567 \n\
1335| KCD | 0xABCDEF0123 \n\
1336";
1337        let db = KeyDb::parse(text).unwrap();
1338        let id = parse_hex_array_20_legacy("0123456789ABCDEF0123456789ABCDEF01234567").unwrap();
1339        let rec = db.disc_record(&id).unwrap();
1340        assert_eq!(
1341            rec.kcd.as_deref(),
1342            Some(&[0xAB, 0xCD, 0xEF, 0x01, 0x23][..])
1343        );
1344    }
1345
1346    // ---- `|`-leader record rejection tests ------------------------------
1347
1348    /// DK with the wrong byte-count for `DEVICE_KEY` is rejected (and
1349    /// the parser skips it rather than failing the load).
1350    #[test]
1351    fn rejects_dk_with_bad_device_key_length() {
1352        let line = "| DK | DEVICE_KEY 0x0001 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
1353        let db = KeyDb::parse(line).unwrap();
1354        assert!(db.device_keys().is_empty());
1355    }
1356
1357    /// DK missing the required `KEY_UV` field is rejected.
1358    #[test]
1359    fn rejects_dk_missing_required_field() {
1360        let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_U_MASK_SHIFT 0x17";
1361        let db = KeyDb::parse(line).unwrap();
1362        assert!(db.device_keys().is_empty());
1363    }
1364
1365    /// PK with the wrong hex length is rejected.
1366    #[test]
1367    fn rejects_pk_with_bad_length() {
1368        let line = "| PK | 0xAABBCC";
1369        let db = KeyDb::parse(line).unwrap();
1370        assert!(db.processing_keys().is_empty());
1371    }
1372
1373    /// HC whose embedded length field disagrees with its actual byte
1374    /// count is rejected.
1375    #[test]
1376    fn rejects_hc_with_mismatched_internal_length() {
1377        // Cert says it's 0x0064 = 100 bytes in the header but is only 8 long.
1378        let line = "| HC | HOST_PRIV_KEY 0x0102030405060708090A0B0C0D0E0F1011121314 | HOST_CERT 0x0203006401020304";
1379        let db = KeyDb::parse(line).unwrap();
1380        assert!(db.host_certs().is_empty());
1381    }
1382
1383    /// A `| VID |` row outside any `DISCID` scope is rejected.
1384    #[test]
1385    fn rejects_vid_without_discid() {
1386        let line = "| VID | 0xAABBCCDDEEFF00112233445566778899";
1387        let db = KeyDb::parse(line).unwrap();
1388        assert!(db.disc_records().is_empty());
1389    }
1390
1391    /// Unrecognised leader is rejected without aborting parse.
1392    #[test]
1393    fn rejects_unknown_leader() {
1394        let text = "| WHAT | 0x00 \n| PK | 0x00112233445566778899AABBCCDDEEFF";
1395        let db = KeyDb::parse(text).unwrap();
1396        // PK still landed.
1397        assert_eq!(db.processing_keys().len(), 1);
1398    }
1399
1400    // ---- mixed-file test ------------------------------------------------
1401
1402    /// A KEYDB.cfg combining legacy `<DISC_ID>=V<VUK>` lines, DK / PK /
1403    /// HC headers, and a DISCID-scoped record set.
1404    #[test]
1405    fn parses_mixed_keydb_file() {
1406        let text = "\
1407; AACS keydb.cfg — synthetic mixed test\n\
1408\n\
14090000000000000000000000000000000000000001 = V 0102030405060708090A0B0C0D0E0F10 | Legacy Disc A\n\
1410\n\
1411| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48\n\
1412| DK | DEVICE_KEY 0x101112131415161718191A1B1C1D1E1F | DEVICE_NODE 0x0C00 | KEY_UV 0x00000A00 | KEY_U_MASK_SHIFT 0x0B ; MKBv49-MKBv71\n\
1413\n\
1414| PK | 0xAABBCCDDEEFF00112233445566778899 ; MKBv12\n\
1415| PK | 0xBBCCDDEEFF0011223344556677889900 ; MKBv24-MKBv48\n\
1416\n\
1417| DISCID | 0x0123456789ABCDEF0123456789ABCDEF01234567 \n\
1418| VUK | 0xD8C278536EE614B877FCF3E4DD631091 \n\
1419| TK | 0x22222222222222222222222222222222 \n\
1420";
1421        let db = KeyDb::parse(text).unwrap();
1422        // Legacy entry survives.
1423        let legacy_id =
1424            parse_hex_array_20_legacy("0000000000000000000000000000000000000001").unwrap();
1425        assert_eq!(
1426            db.entry_for_disc(&legacy_id).unwrap().label.as_deref(),
1427            Some("Legacy Disc A")
1428        );
1429        assert_eq!(db.len(), 1);
1430        // DK + PK rows accumulated.
1431        assert_eq!(db.device_keys().len(), 2);
1432        assert_eq!(db.processing_keys().len(), 2);
1433        assert_eq!(db.device_keys()[0].key_u_mask_shift, 0x17);
1434        assert_eq!(db.device_keys()[1].key_u_mask_shift, 0x0B);
1435        // DISCID scope captured VUK + TK.
1436        let scoped_id =
1437            parse_hex_array_20_legacy("0123456789ABCDEF0123456789ABCDEF01234567").unwrap();
1438        let rec = db.disc_record(&scoped_id).unwrap();
1439        assert_eq!(rec.vuk.unwrap().as_bytes()[0], 0xD8);
1440        assert_eq!(rec.title_keys, vec![[0x22; 16]]);
1441        // vuk_for_disc finds both maps.
1442        assert_eq!(db.vuk_for_disc(&legacy_id).unwrap().as_bytes()[0], 0x01);
1443        assert_eq!(db.vuk_for_disc(&scoped_id).unwrap().as_bytes()[0], 0xD8);
1444    }
1445
1446    /// Backward-compat: the pre-Phase-A integration-test fixture (legacy
1447    /// per-disc lines only) still parses to exactly the same shape it
1448    /// did before the `|`-leader code path was added.
1449    #[test]
1450    fn legacy_only_file_unchanged() {
1451        let text = "\
1452; legacy-only fixture\n\
14530000000000000000000000000000000000000001 = V 0102030405060708090A0B0C0D0E0F10 | Synthetic A\n\
14540000000000000000000000000000000000000002 = V 1112131415161718191A1B1C1D1E1F20 ; trailing comment\n\
14550000000000000000000000000000000000000003 = V 2122232425262728292A2B2C2D2E2F30 | Disc with | pipes | in label\n\
1456";
1457        let db = KeyDb::parse(text).unwrap();
1458        assert_eq!(db.len(), 3);
1459        assert!(db.device_keys().is_empty());
1460        assert!(db.processing_keys().is_empty());
1461        assert!(db.host_certs().is_empty());
1462        assert!(db.drive_certs().is_empty());
1463        assert!(db.disc_records().is_empty());
1464    }
1465}