libfreemkv: dir:// — decrypted file-tree extraction (Disc::extract_tree)

Sibling of Disc::copy specialized to write per-file instead of a whole ISO
image, decrypting on the way out: walk the UDF tree, read each file's extents
through the shared DecryptingSectorSource (AACS unit-aligned, CSS per-VTS),
strip AACS/, sanitize host paths per component, .partial+rename, 1-shot with
per-file loss accounting (no mapfile; recovery stays the iso:// multipass
path). Reuses UdfFs + the decrypt seam; only the per-file orchestration is new.
This commit is contained in:
Matthew Jackson
2026-06-24 20:46:31 -07:00
parent 9a3f6b7313
commit 1767cf67b6
6 changed files with 1501 additions and 6 deletions
+57 -3
View File
@@ -49,6 +49,10 @@ pub enum StreamUrl {
Stdio,
/// ISO disc image file.
Iso { path: PathBuf },
/// Decrypted file-tree output directory (`dir://`). A sink that writes
/// per-file decrypted bytes (not muxed PES frames), so it never flows
/// through `output()`; the CLI routes a `Dir` dest to `Disc::extract_tree`.
Dir { path: PathBuf },
/// Null sink (write-only, discards data).
Null,
/// Unrecognized URL.
@@ -65,6 +69,7 @@ impl StreamUrl {
StreamUrl::Network { .. } => "network",
StreamUrl::Stdio => "stdio",
StreamUrl::Iso { .. } => "iso",
StreamUrl::Dir { .. } => "dir",
StreamUrl::Null => "null",
StreamUrl::Unknown { .. } => "unknown",
}
@@ -75,9 +80,10 @@ impl StreamUrl {
match self {
StreamUrl::Disc { device: Some(p) } => p.to_str().unwrap_or(""),
StreamUrl::Disc { device: None } => "",
StreamUrl::M2ts { path } | StreamUrl::Mkv { path } | StreamUrl::Iso { path } => {
path.to_str().unwrap_or("")
}
StreamUrl::M2ts { path }
| StreamUrl::Mkv { path }
| StreamUrl::Iso { path }
| StreamUrl::Dir { path } => path.to_str().unwrap_or(""),
StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "",
StreamUrl::Unknown { raw } => raw,
@@ -140,6 +146,11 @@ pub fn parse_url(url: &str) -> StreamUrl {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("dir://") {
return StreamUrl::Dir {
path: PathBuf::from(rest),
};
}
StreamUrl::Unknown {
raw: url.to_string(),
}
@@ -370,6 +381,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
Ok(Box::new(NetworkStream::listen(addr)?))
}
StreamUrl::Stdio => Ok(Box::new(StdioStream::input())),
// `dir://` is an output-only sink (decrypted file tree); it is never a
// PES source. Mirror `null://` → write-only.
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
@@ -423,6 +437,11 @@ pub fn output(
StreamUrl::Null => Ok(Box::new(NullStream::new(title))),
StreamUrl::Disc { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Iso { .. } => Err(crate::error::Error::StreamReadOnly.into()),
// `dir://` is NOT a PES sink — it writes raw decrypted files, not muxed
// frames. A stray `dir://` routed into the mux/PES path fails loudly,
// exactly the category the crate already rejects for `iso://`. The CLI
// routes a `dir://` dest to `Disc::extract_tree` before reaching here.
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
}
@@ -814,6 +833,41 @@ mod tests {
);
}
/// `dir://PATH/` parses to `StreamUrl::Dir` with the raw remainder as the
/// path; it is a SINK (not a disc source), so `is_disc_source()` is false.
#[test]
fn parse_dir_url_is_sink_not_disc_source() {
match parse_url("dir://out/movie/") {
StreamUrl::Dir { path } => {
assert_eq!(path, PathBuf::from("out/movie/"));
}
other => panic!("dir:// must parse to Dir, got {other:?}"),
}
assert_eq!(parse_url("dir://x").scheme(), "dir");
assert_eq!(parse_url("dir://x/y").path_str(), "x/y");
assert!(
!parse_url("dir://x").is_disc_source(),
"dir:// is a sink, never a disc source"
);
}
/// `dir://` is output-only: `input()` rejects it (StreamWriteOnly →
/// Unsupported), and `output()` rejects it too (StreamReadOnly →
/// Unsupported) because it is NOT a PES sink — the CLI routes it to
/// `Disc::extract_tree` before the mux path.
#[test]
fn dir_url_is_not_a_pes_stream_either_direction() {
assert_eq!(
input_err_kind("dir://out/"),
std::io::ErrorKind::Unsupported
);
let t = DiscTitle::empty();
assert_eq!(
output_err_kind("dir://out/", &t),
std::io::ErrorKind::Unsupported
);
}
/// output() to network:// with no port must fail validation
/// (StreamUrlMissingPort, E9004 → InvalidInput) before any TcpStream.
#[test]