This commit is contained in:
Ethan O'Brien
2026-08-15 22:20:14 -05:00
parent cb40d74c2c
commit d975b87799
18 changed files with 2015 additions and 18 deletions

View File

@@ -0,0 +1,121 @@
// Structural validation of a VMD (Vocaloid Motion Data) upload: the header
// magic plus a full section walk with bounds checks, so a truncated or
// corrupt file is rejected cheaply at upload time instead of crashing a
// client mid-live. Nothing here decodes the animation - the stored bytes are
// served verbatim and the client owns the semantics.
//
// Layout (little-endian): 30-byte magic "Vocaloid Motion Data 0002"
// (NUL-padded), 20-byte model name, then up to 6 sections, each a uint32
// count followed by fixed-size records - bone (111), morph (23), camera (61),
// light (28), self-shadow (9) - and the property/IK section whose records are
// variable-sized (9 bytes + 21 per IK entry). Camera-only and motion-only
// files legitimately end early: EOF on a section boundary reads as count 0.
const MAGIC_V2: &[u8] = b"Vocaloid Motion Data 0002";
const MAGIC_V1: &[u8] = b"Vocaloid Motion Data file";
const HEADER_LEN: usize = 30 + 20;
// (record size, section name) for the fixed-size sections, in file order
const FIXED_SECTIONS: &[(usize, &str)] = &[
(111, "bone"),
(23, "morph"),
(61, "camera"),
(28, "light"),
(9, "self-shadow")
];
fn read_count(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
Some(u32::from_le_bytes(bytes.get(offset..end)?.try_into().unwrap()))
}
pub fn validate(label: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() < HEADER_LEN {
return Err(format!("'{}' is too short to be a VMD file", label));
}
if bytes.starts_with(MAGIC_V1) {
return Err(format!("'{}' is a version 1 VMD (\"Vocaloid Motion Data file\") - re-save it as version 2 in MMD", label));
}
if !bytes.starts_with(MAGIC_V2) {
return Err(format!("'{}' is not a VMD file (missing the \"Vocaloid Motion Data 0002\" header)", label));
}
let mut offset = HEADER_LEN;
for (record_size, section) in FIXED_SECTIONS {
// EOF exactly on a section boundary: the remaining sections are absent
if offset == bytes.len() {
return Ok(());
}
let Some(count) = read_count(bytes, offset) else {
return Err(format!("'{}' is truncated in the {} section header", label, section));
};
offset += 4;
let section_len = (count as usize).checked_mul(*record_size)
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
.ok_or(format!("'{}' is truncated: the {} section claims {} records past the end of the file", label, section, count))?;
offset += section_len;
}
// Property/IK section: uint32 frame, byte visible, uint32 ikCount, then
// ikCount x (20-byte bone name + 1-byte enabled)
if offset == bytes.len() {
return Ok(());
}
let Some(count) = read_count(bytes, offset) else {
return Err(format!("'{}' is truncated in the property section header", label));
};
offset += 4;
for _ in 0..count {
let Some(ik_count) = read_count(bytes, offset + 5) else {
return Err(format!("'{}' is truncated in the property section", label));
};
let record_len = (ik_count as usize).checked_mul(21)
.and_then(|len| len.checked_add(9))
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
.ok_or(format!("'{}' is truncated in the property section", label))?;
offset += record_len;
}
// Trailing bytes after the last section are tolerated, like MMD does
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structure_walk_accepts_real_shapes_and_rejects_corruption() {
let vmd = crate::router::custom_3dmv::tests::test_vmd(1);
assert!(validate("motion_1", &vmd).is_ok());
// Camera-only file: sections end after camera
let cam = crate::router::custom_3dmv::tests::test_camera_vmd(2);
assert!(validate("camera", &cam).is_ok());
// A bare header with every section absent is structurally fine
let mut bare = Vec::new();
bare.extend(MAGIC_V2);
bare.resize(HEADER_LEN, 0);
assert!(validate("motion_1", &bare).is_ok());
// Truncations and lies
assert!(validate("motion_1", &vmd[..vmd.len() - 1]).unwrap_err().contains("truncated"));
assert!(validate("motion_1", &vmd[..HEADER_LEN + 2]).unwrap_err().contains("truncated"));
let mut liar = vmd.clone();
liar[HEADER_LEN] = 200; // bone count far past EOF
assert!(validate("motion_1", &liar).unwrap_err().contains("claims 200 records"));
// A count that would overflow the length math
let mut overflow = bare.clone();
overflow.extend(u32::MAX.to_le_bytes());
assert!(validate("motion_1", &overflow).unwrap_err().contains("truncated"));
// Wrong or old magic
assert!(validate("motion_1", b"garbage").unwrap_err().contains("too short"));
let mut wrong = vmd.clone();
wrong[0] = b'X';
assert!(validate("motion_1", &wrong).unwrap_err().contains("not a VMD"));
let mut v1 = vmd.clone();
v1[..MAGIC_V1.len()].copy_from_slice(MAGIC_V1);
assert!(validate("motion_1", &v1).unwrap_err().contains("version 1"));
}
}