KeydbSource owns keydb save + update

Move the keydb save/validation/atomic-write path out of libfreemkv onto
KeydbSource. New KeydbSource::save(bytes) validates + decompresses (zip /
gz / plain, decompressed-size capped) and crash-safely writes to the
source's OWN path (sibling-temp + fsync + rename + parent-dir fsync) —
not a hardcoded default, so the caller chooses the destination.
KeydbSource::update(fetch, url) calls an INJECTED fetch closure then
save, keeping this crate transport-agnostic on the update path (the app
supplies its own TLS / SSRF-guarded transport). UpdateResult moves here
and is re-exported. Add flate2 + zip (already in the resolved graph via
libfreemkv) for decompression; no new HTTP stack.
This commit is contained in:
Matthew Jackson
2026-06-26 17:34:03 -07:00
parent 946632d9fb
commit 17fcf6d8f9
4 changed files with 353 additions and 2 deletions
+5
View File
@@ -2,6 +2,11 @@
## [1.1.0-beta.1] — UNRELEASED
### Added
- `KeydbSource` now owns keydb save + update (atomic write to the source's own
path); honors the caller-supplied location.
### Fixed
- **Processing-Key decryption restored.** A keydb Processing Key is again driven
+4
View File
@@ -16,6 +16,10 @@ libfreemkv = "1.0.0-rc.5.3"
ureq = { version = "2", features = ["json"] }
serde_json = "1"
base64 = "0.22"
# KeydbSource::save: decompress a downloaded keydb (.zip / .gz). Same versions
# already in the resolved graph via libfreemkv — no new tree, no new HTTP stack.
flate2 = "1"
zip = { version = "2", default-features = false, features = ["deflate"] }
# Structural begin/end logging around the keyserver round-trip (never logs
# key material).
tracing = "0.1"
+343 -1
View File
@@ -28,7 +28,8 @@
//! call with which material. Returning an empty `Vec` is a genuine "no key for
//! this disc here".
use std::path::PathBuf;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use libfreemkv::aacs::{
HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk,
@@ -38,6 +39,19 @@ use libfreemkv::{Error, KeySource};
use crate::keydb_format::KeyDb;
/// Upper bound on decompressed keydb size. The published keydb is a few MiB;
/// 64 MiB is a generous ceiling that still caps a decompression bomb (a tiny
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread).
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
#[derive(Debug)]
pub struct UpdateResult {
pub path: PathBuf,
pub entries: usize,
pub bytes: usize,
}
/// A [`KeySource`] backed by a local `keydb.cfg` file.
pub struct KeydbSource {
path: PathBuf,
@@ -49,6 +63,69 @@ impl KeydbSource {
Self { path: path.into() }
}
/// Validate, decompress, and crash-safely persist raw keydb bytes (plain
/// text, `.zip`, or `.gz`) to THIS source's own [`path`](Self::path).
///
/// The bytes are decompressed (zip / gz / plain), the result is checked for
/// at least one recognisable keydb entry, then atomically written to the
/// source's path (sibling-temp + fsync + rename + parent-dir fsync). The
/// decompressed size is capped at [`MAX_KEYDB_BYTES`] so a decompression
/// bomb can't OOM the caller (e.g. the daily-refresh thread). Writing to the
/// source's own path — not a hardcoded default — means the caller decides
/// the destination (CLI `--keydb`, the autorip service path, …).
pub fn save(&self, bytes: &[u8]) -> Result<UpdateResult, Error> {
let text = if bytes.starts_with(b"PK\x03\x04") {
extract_zip(bytes)?
} else if bytes.starts_with(&[0x1f, 0x8b]) {
read_capped_to_string(flate2::read::GzDecoder::new(bytes))?
} else {
// Plain-text body: route through the same capped reader as the
// gz/zip branches so an oversized uncompressed upload can't bypass
// MAX_KEYDB_BYTES.
read_capped_to_string(std::io::Cursor::new(bytes))?
};
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);
}
write_atomic(&self.path, &text)?;
Ok(UpdateResult {
path: self.path.clone(),
entries,
bytes: text.len(),
})
}
/// Fetch keydb bytes from `url` via the caller-supplied `fetch` transport,
/// then validate + save them to this source's path.
///
/// The transport is INJECTED: this crate stays transport-agnostic on the
/// update path so the application supplies its own TLS / SSRF-guarded fetch
/// (the `freemkv` CLI passes its `keydb_fetch::fetch`). `fetch` returns the
/// raw response body (plain text, `.zip`, or `.gz`); [`save`](Self::save)
/// does the verify + atomic write.
pub fn update(
&self,
fetch: impl Fn(&str) -> Result<Vec<u8>, Error>,
url: &str,
) -> Result<UpdateResult, Error> {
let bytes = fetch(url)?;
self.save(&bytes)
}
/// The host certificate(s) in this keydb — the second kind of data the one
/// keydb file holds (alongside decryption keys). The app passes these to the
/// live-drive scan as `DriveCredentials` for the AACS handshake. Empty if
@@ -155,6 +232,101 @@ impl KeydbSource {
}
}
/// Read a decompressed stream into a `String` with a hard size ceiling.
/// Returns [`Error::KeydbInvalid`] if the input exceeds the cap, or
/// [`Error::KeydbParse`] if the bytes are not valid UTF-8.
fn read_capped_to_string<R: Read>(reader: R) -> Result<String, Error> {
let mut buf = Vec::new();
// Read one byte past the cap so an exactly-at-cap stream is accepted but
// anything larger is rejected.
reader
.take(MAX_KEYDB_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|_| Error::KeydbParse)?;
if buf.len() as u64 > MAX_KEYDB_BYTES {
return Err(Error::KeydbInvalid);
}
String::from_utf8(buf).map_err(|_| Error::KeydbParse)
}
/// Extract the first `*.cfg` member of a zip archive as a capped `String`.
fn extract_zip(data: &[u8]) -> Result<String, Error> {
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 file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
return read_capped_to_string(file);
}
}
Err(Error::KeydbInvalid)
}
/// Write `text` to `path` crash-safely (create parent dir, write a sibling temp
/// file, fsync, then atomic rename, then fsync the parent dir).
///
/// keydb.cfg is the single source of AACS truth, and save/update run unattended
/// (first-boot download + daily-refresh thread, with a container restart on
/// every release). A bare in-place `fs::write` truncates the file before
/// writing, so a SIGKILL (docker stop's grace window), OOM-kill, power loss, or
/// ENOSPC mid-write would leave the keydb half-written — the prior good copy
/// already gone. A truncated keydb doesn't error at write time; it silently
/// breaks key resolution on every later AACS rip. Writing to a temp file then
/// renaming (POSIX rename is atomic within a filesystem) means an interrupted
/// update leaves the previous keydb fully intact.
///
/// The fsync MUST succeed before the rename: a `sync_all` failure (ENOSPC,
/// ESTALE on the bind-mounted volume) means the kernel never guaranteed the
/// bytes reached stable storage, so publishing them via rename would defeat
/// crash-safety. The temp name is unique per call (pid + monotonic counter) so a
/// concurrent update can't share a fixed temp path and rename a mangled file
/// over the keydb.
fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
let werr = || Error::KeydbWrite {
path: path.display().to_string(),
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| {
tracing::warn!(error = %e, path = %path.display(), "keydb dir create failed");
werr()
})?;
}
let tmp = {
use std::sync::atomic::{AtomicU64, Ordering};
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
path.with_extension(format!(
"tmp.{}.{}",
std::process::id(),
TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
))
};
let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::File::create(&tmp)?;
f.write_all(text.as_bytes())?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
tracing::warn!(error = %e, path = %path.display(), "keydb write/fsync failed; keydb unchanged");
return Err(werr());
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
tracing::warn!(error = %e, path = %path.display(), "keydb rename failed; keydb unchanged");
return Err(werr());
}
// Durably commit the new dirent: on POSIX filesystems (ext2, some NFS) a
// crash right after the rename can lose the directory entry even though the
// rename returned. Best-effort (swallowed on failure); no-op on Windows.
if let Some(dir) = path.parent() {
libfreemkv::io::fsync::dir(dir);
}
Ok(())
}
impl KeySource for KeydbSource {
/// Resolve this disc's terminal Unit Keys from the keydb. A missing /
/// unreadable keydb is not an error — it simply yields no keys (another
@@ -596,4 +768,174 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
// ── save / update (moved from libfreemkv::keydb) ──────────────────────────
// Per project convention, tests never touch /tmp (wiped on reboot). Anchor
// scratch under the crate's target/ (gitignored), not /tmp.
fn scratch(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let d = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target/test-scratch")
.join(format!("keydb-save-{}-{}-{}", std::process::id(), tag, n));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The headline behaviour of the move: `save` writes to the SOURCE'S OWN
/// path, never a hardcoded default. Creates the parent dir, reports the
/// destination, and round-trips the content.
#[test]
fn save_writes_to_the_sources_own_path() {
let dir = scratch("save-path");
let target = dir.join("nested").join("mykeys.cfg");
let src = KeydbSource::new(&target);
let body = b"0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF\n";
let result = src.save(body).expect("save must succeed");
assert_eq!(
result.path, target,
"save must write to the source's own path, not a default"
);
assert_eq!(result.entries, 1, "one 0x entry");
assert!(target.exists(), "keydb file must exist at the source path");
assert!(
std::fs::read_to_string(&target)
.unwrap()
.contains("0xDEADBEEF"),
"content must round-trip"
);
}
/// `update` runs the injected fetch, then saves the returned bytes to the
/// source's path — the transport is supplied by the caller, never built
/// here.
#[test]
fn update_uses_injected_fetch_then_saves_to_path() {
let dir = scratch("update-path");
let target = dir.join("k.cfg");
let src = KeydbSource::new(&target);
let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD\n".to_vec();
let result = src
.update(|_url| Ok(body.clone()), "http://example.invalid/keydb.zip")
.expect("update must succeed with a good fetch");
assert_eq!(result.path, target, "update must save to the source's path");
assert_eq!(result.entries, 1);
assert!(target.exists());
}
/// A failing injected fetch propagates as-is; nothing is written.
#[test]
fn update_propagates_fetch_error_and_writes_nothing() {
let dir = scratch("update-err");
let target = dir.join("k.cfg");
let src = KeydbSource::new(&target);
let result = src.update(
|_| {
Err(Error::KeydbConnect {
host: "x".to_string(),
})
},
"http://x/",
);
assert!(matches!(result, Err(Error::KeydbConnect { .. })));
assert!(!target.exists(), "a fetch failure must write no keydb");
}
/// `save` rejects bytes with no recognisable keydb entries.
#[test]
fn save_rejects_empty_text() {
let dir = scratch("save-empty");
let src = KeydbSource::new(dir.join("k.cfg"));
let garbage = b"this is not a keydb\njust random text\n";
assert!(matches!(src.save(garbage), Err(Error::KeydbInvalid)));
}
/// `save` recognises gzip magic (0x1f 0x8b) and routes to the gz decoder; a
/// truncated gzip is a parse/invalid error, never a plain-text UTF-8 error.
#[test]
fn save_recognises_gzip_magic() {
let dir = scratch("save-gz");
let src = KeydbSource::new(dir.join("k.cfg"));
let bad_gz = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03];
match src.save(&bad_gz).unwrap_err() {
Error::KeydbParse | Error::KeydbInvalid => {}
e => panic!("wrong error kind for truncated gzip: {e:?}"),
}
}
/// `save` recognises ZIP magic (PK\x03\x04) and routes to extract_zip; a
/// truncated zip is a parse/invalid error, never a plain-text UTF-8 error.
#[test]
fn save_recognises_zip_magic() {
let dir = scratch("save-zip");
let src = KeydbSource::new(dir.join("k.cfg"));
let bad_zip = b"PK\x03\x04garbage that is not a real zip";
match src.save(bad_zip).unwrap_err() {
Error::KeydbParse | Error::KeydbInvalid => {}
e => panic!("wrong error for bad zip: {e:?}"),
}
}
/// `read_capped_to_string` rejects input over the cap (decompression-bomb
/// guard) and accepts exactly at the cap.
#[test]
fn read_capped_to_string_enforces_size_cap() {
let too_big = vec![b'A'; (MAX_KEYDB_BYTES + 1) as usize];
assert!(matches!(
read_capped_to_string(std::io::Cursor::new(too_big)),
Err(Error::KeydbInvalid)
));
let at_cap = vec![b'A'; MAX_KEYDB_BYTES as usize];
assert!(read_capped_to_string(std::io::Cursor::new(at_cap)).is_ok());
// Non-UTF-8 is a parse error, not the size error.
assert!(matches!(
read_capped_to_string(std::io::Cursor::new(vec![0xFFu8, 0xFE])),
Err(Error::KeydbParse)
));
}
/// `write_atomic` replaces an existing file in place and leaves no stray
/// temp sibling.
#[test]
fn write_atomic_replaces_existing_and_leaves_no_temp() {
let dir = scratch("atomic");
let path = dir.join("freemkv").join("keydb.cfg");
write_atomic(&path, "0xAAAA = old\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xAAAA = old\n");
write_atomic(&path, "0xBBBB = new\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xBBBB = new\n");
let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
}
/// A failed write (parent path is a file → ENOTDIR on create_dir_all) leaves
/// the prior keydb intact and surfaces KeydbWrite.
#[test]
fn write_atomic_failure_preserves_prior_keydb() {
let dir = scratch("preserve");
let good = dir.join("keydb.cfg");
write_atomic(&good, "0xGOOD = keep\n").unwrap();
let doomed = good.join("freemkv").join("keydb.cfg");
assert!(matches!(
write_atomic(&doomed, "0xBAD = partial\n"),
Err(Error::KeydbWrite { .. })
));
assert_eq!(std::fs::read_to_string(&good).unwrap(), "0xGOOD = keep\n");
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ mod keydb_format;
mod online;
mod paths;
pub use keydb::KeydbSource;
pub use keydb::{KeydbSource, UpdateResult};
pub use online::{OnlineSource, validate_keyserver_url};
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};