mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
122 lines
5.2 KiB
Rust
122 lines
5.2 KiB
Rust
// 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"));
|
|
}
|
|
}
|