diff --git a/README.md b/README.md index db19e93..a98d45b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ # libfreemkv -Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, and content reading in one crate. Bundled drive profiles — no external files needed. +Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed. + +Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top. **[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)** @@ -42,9 +44,11 @@ while let Some(unit) = reader.read_unit()? { ## What It Does -- **Drive access** — open, identify, unlock for raw reads -- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels -- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0) +- **Drive access** — open, identify, unlock, eject +- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info +- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe) +- **AACS decryption** — transparent key resolution and content decrypt (1.0, 2.0 in progress) +- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP) - **Content reading** — sector reads with automatic decryption AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption. @@ -61,8 +65,9 @@ Disc — scan titles, streams, AACS state ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions ├── MPLS parser — playlists → titles + clips + streams ├── CLPI parser — clip info → EP map → sector extents - ├── JAR parser — BD-J audio track labels - └── AACS — key resolution + content decryption + ├── Labels — 5 BD-J format parsers (detect + parse) + ├── AACS — key resolution + content decryption + └── KEYDB — download + verify + save ``` See [docs/](docs/) for detailed technical documentation on each module. @@ -80,6 +85,7 @@ All errors are structured with numeric codes. No user-facing English text — ap | E5xxx | I/O errors | | E6xxx | Disc format errors | | E7xxx | AACS errors | +| E8xxx | KEYDB update errors | ## Platform Support diff --git a/src/error.rs b/src/error.rs index ed7ec8d..d511ed8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,6 +32,11 @@ pub const E_SCSI_TIMEOUT: u16 = 4001; pub const E_IO_ERROR: u16 = 5000; pub const E_DISC_ERROR: u16 = 6000; pub const E_AACS_ERROR: u16 = 7000; +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; // ── Error enum ────────────────────────────────────────────────────────────── @@ -52,6 +57,11 @@ pub enum Error { IoError { source: std::io::Error }, DiscError { detail: String }, AacsError { detail: String }, + KeydbConnect { host: String }, + KeydbHttp { status: u16 }, + KeydbInvalid, + KeydbWrite { path: String }, + KeydbParse, } impl Error { @@ -72,6 +82,11 @@ impl Error { Error::IoError { .. } => E_IO_ERROR, Error::DiscError { .. } => E_DISC_ERROR, Error::AacsError { .. } => E_AACS_ERROR, + 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, } } } @@ -115,6 +130,16 @@ impl std::fmt::Display for Error { write!(f, "E{}: {}", E_DISC_ERROR, detail), Error::AacsError { detail } => write!(f, "E{}: {}", E_AACS_ERROR, detail), + Error::KeydbConnect { host } => + write!(f, "E{}: {}", E_KEYDB_CONNECT, host), + Error::KeydbHttp { status } => + write!(f, "E{}: {}", E_KEYDB_HTTP, status), + Error::KeydbInvalid => + write!(f, "E{}", E_KEYDB_INVALID), + Error::KeydbWrite { path } => + write!(f, "E{}: {}", E_KEYDB_WRITE, path), + Error::KeydbParse => + write!(f, "E{}", E_KEYDB_PARSE), } } } diff --git a/src/keydb.rs b/src/keydb.rs new file mode 100644 index 0000000..5522a3a --- /dev/null +++ b/src/keydb.rs @@ -0,0 +1,161 @@ +//! KEYDB.cfg updater — HTTP GET, unzip, verify, save. +//! +//! Zero external HTTP dependencies. Raw TCP for HTTP GET. +//! Uses `zip` and `flate2` (already in deps) for extraction. + +use crate::error::{Error, Result}; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::PathBuf; + +/// Standard keydb storage path. +pub fn default_path() -> Result { + let home = std::env::var("HOME").map_err(|_| Error::KeydbWrite { + path: "HOME".into(), + })?; + Ok(PathBuf::from(home).join(".config").join("freemkv").join("keydb.cfg")) +} + +/// Download a KEYDB from a URL, verify, save to the standard path. +pub fn update(url: &str) -> Result { + let body = http_get(url)?; + save(&body) +} + +/// Verify and save raw keydb bytes (plain text, .zip, or .gz). +pub fn save(data: &[u8]) -> Result { + let text = if data.starts_with(b"PK\x03\x04") { + extract_zip(data)? + } else if data.starts_with(&[0x1f, 0x8b]) { + let mut dec = flate2::read::GzDecoder::new(data); + let mut out = String::new(); + dec.read_to_string(&mut out).map_err(|_| Error::KeydbParse)?; + out + } else { + String::from_utf8(data.to_vec()).map_err(|_| Error::KeydbParse)? + }; + + let entries = text.lines() + .filter(|l| { + let t = l.trim(); + t.starts_with("0x") || t.starts_with("| DK") || t.starts_with("| PK") || t.starts_with("| HC") + }) + .count(); + + if entries == 0 { + return Err(Error::KeydbInvalid); + } + + let path = default_path()?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|_| Error::KeydbWrite { + path: path.display().to_string(), + })?; + } + std::fs::write(&path, &text).map_err(|_| Error::KeydbWrite { + path: path.display().to_string(), + })?; + + Ok(UpdateResult { path, entries, bytes: text.len() }) +} + +#[derive(Debug)] +pub struct UpdateResult { + pub path: PathBuf, + pub entries: usize, + pub bytes: usize, +} + +fn http_get(url: &str) -> Result> { + let (host, port, path) = parse_url(url)?; + + for _ in 0..5 { + let addr = format!("{}:{}", host, port); + let mut stream = TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect { + host: host.clone(), + })?; + stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok(); + + let request = format!( + "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n", + path, host + ); + stream.write_all(request.as_bytes()).map_err(|_| Error::KeydbConnect { + host: host.clone(), + })?; + + let mut response = Vec::new(); + stream.read_to_end(&mut response).map_err(|_| Error::KeydbConnect { + host: host.clone(), + })?; + + let header_end = find_header_end(&response).ok_or(Error::KeydbParse)?; + let headers = std::str::from_utf8(&response[..header_end]).unwrap_or(""); + let body = &response[header_end + 4..]; + + if let Some(location) = extract_header(headers, "Location") { + return http_get(&location); + } + + let status = parse_status(headers); + if status != 200 { + return Err(Error::KeydbHttp { status }); + } + + return Ok(body.to_vec()); + } + + Err(Error::KeydbHttp { status: 302 }) +} + +fn parse_url(url: &str) -> Result<(String, u16, String)> { + let url = url.strip_prefix("http://").ok_or(Error::KeydbParse)?; + let (host_port, path) = match url.find('/') { + Some(i) => (&url[..i], &url[i..]), + None => (url, "/"), + }; + let (host, port) = match host_port.find(':') { + Some(i) => (&host_port[..i], host_port[i+1..].parse().unwrap_or(80)), + None => (host_port, 80u16), + }; + Ok((host.to_string(), port, path.to_string())) +} + +fn parse_status(headers: &str) -> u16 { + headers.lines().next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + .unwrap_or(0) +} + +fn find_header_end(data: &[u8]) -> Option { + data.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn extract_header<'a>(headers: &'a str, name: &str) -> Option { + for line in headers.lines() { + if line.len() > name.len() + 2 + && line[..name.len()].eq_ignore_ascii_case(name) + && line.as_bytes()[name.len()] == b':' + { + return Some(line[name.len() + 1..].trim().to_string()); + } + } + None +} + +fn extract_zip(data: &[u8]) -> Result { + let cursor = std::io::Cursor::new(data); + let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i).map_err(|_| Error::KeydbParse)?; + if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") { + let mut text = String::new(); + file.read_to_string(&mut text).map_err(|_| Error::KeydbParse)?; + return Ok(text); + } + } + + Err(Error::KeydbInvalid) +} diff --git a/src/lib.rs b/src/lib.rs index 3fe4b66..25b2b53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,7 @@ pub mod clpi; pub mod disc; pub mod aacs; pub mod labels; +pub mod keydb; pub use error::{Error, Result}; pub use drive::DriveSession;