Catch names the reader cannot tell apart, and in-place content changes
Two dirimage findings, both silent-wrong-output. The per-directory uniqueness check compared raw host names, but the reader does not see raw host names: parse_udf_name trims leading and trailing whitespace and drops any code unit char::from_u32 rejects — which is every half of the surrogate pairs the encoder emits for non-BMP characters. So " 00000.m2ts" and "00000.m2ts", or "A<astral>.m2ts" and "A.m2ts", were two entries at plan time and ONE name at read time. find/read_file take the first match, so a title resolved to the wrong file extents and muxed the wrong bytes at exit 0 — the exact shadowing DirNameCollision exists to prevent. The key is now derived by round-tripping the name through the very encoder and parser that will be used, so it cannot drift from them. A name that survives that round trip as empty is refused outright: it would exist in the image and be addressable by nothing. The new test builds a real folder, calls plan, and was confirmed to FAIL with the fix reverted. Separately, the plan-vs-read revalidation compared file LENGTH only, while the plan depends on CONTENT: a DVD VOB placement comes from bytes 0xC0/0xC4 of its IFO, and IFOs occupy a whole number of sectors so an in-place rewrite keeps the length. A re-authoring tool touching the folder mid-rip would pass the size check while every title extent pointed at stale sectors. mtime is now compared alongside size, and only when both sides report one, so a filesystem without timestamps falls back to the old behaviour rather than failing every read.
This commit is contained in:
+75
-1
@@ -102,6 +102,15 @@ pub(super) struct FileNode {
|
|||||||
pub(super) disc_path: String,
|
pub(super) disc_path: String,
|
||||||
pub(super) host: PathBuf,
|
pub(super) host: PathBuf,
|
||||||
pub(super) size: u64,
|
pub(super) size: u64,
|
||||||
|
/// Host mtime at PLAN time, when the platform reports one.
|
||||||
|
///
|
||||||
|
/// Size alone is content-blind, and the plan depends on CONTENT: a DVD's
|
||||||
|
/// VOB placement is derived from bytes 0xC0/0xC4 of its IFO (`read_head`).
|
||||||
|
/// A re-authoring tool rewriting an IFO in place keeps the length — IFOs
|
||||||
|
/// are a whole number of sectors — so a size check passes while the
|
||||||
|
/// placement the image was built around is stale, and every cell of the
|
||||||
|
/// title then resolves to the wrong sectors at exit 0.
|
||||||
|
pub(super) mtime: Option<std::time::SystemTime>,
|
||||||
pub(super) icb_lba: u32,
|
pub(super) icb_lba: u32,
|
||||||
pub(super) unique_id: u64,
|
pub(super) unique_id: u64,
|
||||||
pub(super) extents: Vec<Extent>,
|
pub(super) extents: Vec<Extent>,
|
||||||
@@ -211,7 +220,26 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<
|
|||||||
if *entries > MAX_ENTRIES {
|
if *entries > MAX_ENTRIES {
|
||||||
return Err(Error::DirImageTooLarge);
|
return Err(Error::DirImageTooLarge);
|
||||||
}
|
}
|
||||||
names.push(name.to_ascii_uppercase());
|
// Key the uniqueness check on the name AS THE READER WILL SEE IT, by
|
||||||
|
// round-tripping through the very encoder and parser that will be used.
|
||||||
|
//
|
||||||
|
// The host name is not that name. `parse_udf_name` trims leading and
|
||||||
|
// trailing whitespace and drops any code unit `char::from_u32` rejects
|
||||||
|
// (a lone surrogate half, i.e. every non-BMP character the encoder
|
||||||
|
// emits as a surrogate pair). So " A.M2TS" and "A.M2TS", or "A<astral>.M2TS"
|
||||||
|
// and "A.M2TS", are distinct hosts that collapse to ONE name on read —
|
||||||
|
// and `find`/`read_file` take the first match, so a title silently
|
||||||
|
// resolves to the wrong file's extents and muxes the wrong bytes at
|
||||||
|
// exit 0. Comparing the raw host names cannot see that; deriving the
|
||||||
|
// key from the same two functions cannot drift from it.
|
||||||
|
let as_read = crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(&name));
|
||||||
|
// Nothing left after the reader is done with it: the entry would exist
|
||||||
|
// in the image and be unaddressable by any name. Refuse rather than
|
||||||
|
// write something no consumer can reach.
|
||||||
|
if as_read.is_empty() {
|
||||||
|
return Err(Error::DirNameCollision { host: child_path });
|
||||||
|
}
|
||||||
|
names.push(as_read.to_ascii_uppercase());
|
||||||
if ft.is_dir() {
|
if ft.is_dir() {
|
||||||
// A directory's File Entry records its link count in 16 bits: one
|
// A directory's File Entry records its link count in 16 bits: one
|
||||||
// per child directory plus one for its own entry in the parent.
|
// per child directory plus one for its own entry in the parent.
|
||||||
@@ -242,6 +270,7 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<
|
|||||||
disc_path: child_path,
|
disc_path: child_path,
|
||||||
host: entry.path(),
|
host: entry.path(),
|
||||||
size: meta.len(),
|
size: meta.len(),
|
||||||
|
mtime: meta.modified().ok(),
|
||||||
icb_lba: 0,
|
icb_lba: 0,
|
||||||
unique_id: 0,
|
unique_id: 0,
|
||||||
extents: Vec::new(),
|
extents: Vec::new(),
|
||||||
@@ -746,6 +775,7 @@ mod tests {
|
|||||||
disc_path: "/BIG.M2TS".into(),
|
disc_path: "/BIG.M2TS".into(),
|
||||||
host: PathBuf::new(),
|
host: PathBuf::new(),
|
||||||
size: MAX_AD_BYTES + 4096,
|
size: MAX_AD_BYTES + 4096,
|
||||||
|
mtime: None,
|
||||||
icb_lba: 0,
|
icb_lba: 0,
|
||||||
unique_id: 0,
|
unique_id: 0,
|
||||||
extents: Vec::new(),
|
extents: Vec::new(),
|
||||||
@@ -778,6 +808,7 @@ mod tests {
|
|||||||
disc_path: "/EMPTY".into(),
|
disc_path: "/EMPTY".into(),
|
||||||
host: PathBuf::new(),
|
host: PathBuf::new(),
|
||||||
size: 0,
|
size: 0,
|
||||||
|
mtime: None,
|
||||||
icb_lba: 0,
|
icb_lba: 0,
|
||||||
unique_id: 0,
|
unique_id: 0,
|
||||||
extents: Vec::new(),
|
extents: Vec::new(),
|
||||||
@@ -805,6 +836,49 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// An earlier version of this test asserted arithmetic about the constants
|
/// An earlier version of this test asserted arithmetic about the constants
|
||||||
/// and never called `plan`, so it would have passed with the guard deleted.
|
/// and never called `plan`, so it would have passed with the guard deleted.
|
||||||
|
/// Two host names that the READER collapses into one must be refused by
|
||||||
|
/// the planner, not written into the image.
|
||||||
|
///
|
||||||
|
/// Audit finding: the uniqueness check compared raw host names, while
|
||||||
|
/// `parse_udf_name` trims whitespace and drops code units `char::from_u32`
|
||||||
|
/// rejects. So " 00000.m2ts" and "00000.m2ts" were two distinct entries at
|
||||||
|
/// plan time and ONE name at read time; `find`/`read_file` take the first
|
||||||
|
/// match, so a title resolved to the wrong file's extents and muxed the
|
||||||
|
/// wrong bytes at exit 0.
|
||||||
|
///
|
||||||
|
/// This calls `plan` on a real folder. It fails if the round-trip key is
|
||||||
|
/// reverted to comparing host names.
|
||||||
|
#[test]
|
||||||
|
fn two_names_the_reader_cannot_tell_apart_are_refused() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"fmkv-shadow-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let stream = dir.join("BDMV/STREAM");
|
||||||
|
std::fs::create_dir_all(&stream).expect("mkdir");
|
||||||
|
std::fs::write(stream.join("00000.m2ts"), b"a").expect("write");
|
||||||
|
// Same name to the reader: it trims the leading space.
|
||||||
|
std::fs::write(stream.join(" 00000.m2ts"), b"b").expect("write");
|
||||||
|
|
||||||
|
// Precondition — if this stops holding the fixture is wrong, not the code.
|
||||||
|
assert_eq!(
|
||||||
|
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(" 00000.m2ts")),
|
||||||
|
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0("00000.m2ts")),
|
||||||
|
"fixture: these two host names must read back identically"
|
||||||
|
);
|
||||||
|
|
||||||
|
let got = plan(&dir);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
assert!(
|
||||||
|
matches!(got, Err(Error::DirNameCollision { .. })),
|
||||||
|
"a name the reader cannot distinguish must be refused, got {got:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_over_long_name_is_refused_by_the_planner() {
|
fn an_over_long_name_is_refused_by_the_planner() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
+21
-2
@@ -79,6 +79,9 @@ struct FileRef {
|
|||||||
host: PathBuf,
|
host: PathBuf,
|
||||||
disc_path: String,
|
disc_path: String,
|
||||||
size: u64,
|
size: u64,
|
||||||
|
/// Host mtime at plan time — see `layout::FileNode::mtime` for why size
|
||||||
|
/// alone is not enough.
|
||||||
|
mtime: Option<std::time::SystemTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A synthesized UDF disc image over a host directory.
|
/// A synthesized UDF disc image over a host directory.
|
||||||
@@ -128,6 +131,7 @@ impl DirImage {
|
|||||||
host: node.host.clone(),
|
host: node.host.clone(),
|
||||||
disc_path: node.disc_path.clone(),
|
disc_path: node.disc_path.clone(),
|
||||||
size: node.size,
|
size: node.size,
|
||||||
|
mtime: node.mtime,
|
||||||
});
|
});
|
||||||
let mut offset = 0u64;
|
let mut offset = 0u64;
|
||||||
for e in &node.extents {
|
for e in &node.extents {
|
||||||
@@ -206,8 +210,23 @@ impl DirImage {
|
|||||||
return Ok(&mut self.open[0].1);
|
return Ok(&mut self.open[0].1);
|
||||||
}
|
}
|
||||||
let f = File::open(&self.files[file].host).map_err(Error::from)?;
|
let f = File::open(&self.files[file].host).map_err(Error::from)?;
|
||||||
let live = f.metadata().map_err(Error::from)?.len();
|
let md = f.metadata().map_err(Error::from)?;
|
||||||
if live != self.files[file].size {
|
// Size AND mtime. Size alone is content-blind, and this plan depends on
|
||||||
|
// content: a DVD's VOB placement comes from bytes 0xC0/0xC4 of its IFO,
|
||||||
|
// and an IFO rewritten in place keeps its length because IFOs occupy a
|
||||||
|
// whole number of sectors. The size check would pass while every title
|
||||||
|
// extent pointed at the wrong sectors — corrupt video behind an intact
|
||||||
|
// structure, reported complete at exit 0.
|
||||||
|
//
|
||||||
|
// Only compared when both sides have a timestamp; a platform or
|
||||||
|
// filesystem that reports none simply falls back to the size check
|
||||||
|
// rather than failing every read.
|
||||||
|
let changed_size = md.len() != self.files[file].size;
|
||||||
|
let changed_mtime = match (self.files[file].mtime, md.modified().ok()) {
|
||||||
|
(Some(planned), Some(live)) => planned != live,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if changed_size || changed_mtime {
|
||||||
return Err(Error::DirImageFileChanged {
|
return Err(Error::DirImageFileChanged {
|
||||||
path: self.files[file].disc_path.clone(),
|
path: self.files[file].disc_path.clone(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user