#[cfg(feature = "python")]
mod python;
use crc32fast::Hasher;
use thiserror::Error;
const MAGIC: &[u8; 4] = b"DVPL";
const FOOTER_SIZE: usize = 20;
pub const COMP_NONE: u32 = 0;
pub const COMP_LZ4: u32 = 1;
pub const COMP_LZ4_HC: u32 = 2;
#[derive(Debug, Error)]
pub enum DvplError {
#[error("File too small ({0} bytes)")]
TooSmall(usize),
#[error("Bad magic: expected DVPL, got {}", format_magic(.0))]
BadMagic([u8; 4]),
#[error("Size mismatch: footer says {expected}, got {actual}")]
SizeMismatch { expected: usize, actual: usize },
#[error("CRC32 mismatch: expected {expected:#010x}, got {actual:#010x}")]
CrcMismatch { expected: u32, actual: u32 },
#[error("Decompressed size mismatch: expected {expected}, got {actual}")]
DecompressedSizeMismatch { expected: usize, actual: usize },
#[error("Unknown compression type: {0}")]
UnknownCompression(u32),
#[error("lz4 error: {0}")]
Lz4(String),
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn format_magic(magic: &[u8; 4]) -> String {
let s: String = magic
.iter()
.map(|&b| {
if b.is_ascii_graphic() || b == b' ' {
(b as char).to_string()
} else {
format!("\\x{b:02x}")
}
})
.collect();
format!("b\"{s}\"")
}
fn crc32(data: &[u8]) -> u32 {
let mut hasher = Hasher::new();
hasher.update(data);
hasher.finalize()
}
fn read_u32_le(buf: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
}
pub fn decode(data: &[u8]) -> Result<Vec<u8>, DvplError> {
if data.len() < FOOTER_SIZE {
return Err(DvplError::TooSmall(data.len()));
}
let footer = &data[data.len() - FOOTER_SIZE..];
let original_size = read_u32_le(footer, 0) as usize;
let compressed_size = read_u32_le(footer, 4) as usize;
let checksum = read_u32_le(footer, 8);
let comp_type = read_u32_le(footer, 12);
let magic: [u8; 4] = footer[16..20].try_into().unwrap();
if magic != *MAGIC {
return Err(DvplError::BadMagic(magic));
}
let payload = &data[..data.len() - FOOTER_SIZE];
if payload.len() != compressed_size {
return Err(DvplError::SizeMismatch {
expected: compressed_size,
actual: payload.len(),
});
}
let calculated_crc = crc32(payload);
if calculated_crc != checksum {
return Err(DvplError::CrcMismatch {
expected: checksum,
actual: calculated_crc,
});
}
let result = match comp_type {
COMP_NONE => payload.to_vec(),
COMP_LZ4 | COMP_LZ4_HC => lz4::block::decompress(payload, Some(original_size as i32))
.map_err(|e| DvplError::Lz4(e.to_string()))?,
_ => return Err(DvplError::UnknownCompression(comp_type)),
};
if result.len() != original_size {
return Err(DvplError::DecompressedSizeMismatch {
expected: original_size,
actual: result.len(),
});
}
Ok(result)
}
pub fn encode(data: &[u8], comp_type: u32) -> Result<Vec<u8>, DvplError> {
let payload = match comp_type {
COMP_NONE => data.to_vec(),
COMP_LZ4 => {
lz4::block::compress(data, None, false).map_err(|e| DvplError::Lz4(e.to_string()))?
}
COMP_LZ4_HC => {
lz4::block::compress(
data,
Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
false,
)
.map_err(|e| DvplError::Lz4(e.to_string()))?
}
_ => return Err(DvplError::UnknownCompression(comp_type)),
};
let checksum = crc32(&payload);
let original_size = data.len() as u32;
let compressed_size = payload.len() as u32;
let mut result = Vec::with_capacity(payload.len() + FOOTER_SIZE);
result.extend_from_slice(&payload);
result.extend_from_slice(&original_size.to_le_bytes());
result.extend_from_slice(&compressed_size.to_le_bytes());
result.extend_from_slice(&checksum.to_le_bytes());
result.extend_from_slice(&comp_type.to_le_bytes());
result.extend_from_slice(MAGIC);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &[u8] = b"Hello World of Tanks Blitz!";
#[test]
fn round_trip_none() {
let encoded = encode(SAMPLE, COMP_NONE).unwrap();
assert_eq!(decode(&encoded).unwrap(), SAMPLE);
}
#[test]
fn round_trip_lz4() {
let encoded = encode(SAMPLE, COMP_LZ4).unwrap();
assert_eq!(decode(&encoded).unwrap(), SAMPLE);
}
#[test]
fn round_trip_lz4_hc() {
let encoded = encode(SAMPLE, COMP_LZ4_HC).unwrap();
assert_eq!(decode(&encoded).unwrap(), SAMPLE);
}
#[test]
fn round_trip_empty() {
let encoded = encode(b"", COMP_LZ4_HC).unwrap();
assert_eq!(decode(&encoded).unwrap(), b"");
}
#[test]
fn footer_has_magic() {
let encoded = encode(SAMPLE, COMP_NONE).unwrap();
assert_eq!(&encoded[encoded.len() - 4..], b"DVPL");
}
#[test]
fn too_small() {
assert!(matches!(decode(b"short"), Err(DvplError::TooSmall(5))));
}
#[test]
fn bad_magic() {
let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
let len = blob.len();
blob[len - 1] = b'X';
assert!(matches!(decode(&blob), Err(DvplError::BadMagic(_))));
}
#[test]
fn crc_mismatch() {
let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
blob[0] ^= 0xFF;
assert!(matches!(decode(&blob), Err(DvplError::CrcMismatch { .. })));
}
#[test]
fn unknown_compression() {
assert!(matches!(
encode(SAMPLE, 99),
Err(DvplError::UnknownCompression(99))
));
}
#[test]
fn size_mismatch() {
let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
let footer = blob[blob.len() - FOOTER_SIZE..].to_vec();
blob.truncate(blob.len() - FOOTER_SIZE - 1);
blob.extend_from_slice(&footer);
assert!(matches!(decode(&blob), Err(DvplError::SizeMismatch { .. })));
}
}