1#[cfg(feature = "python")]
41mod python;
42
43use crc32fast::Hasher;
44use thiserror::Error;
45
46const MAGIC: &[u8; 4] = b"DVPL";
48
49const FOOTER_SIZE: usize = 20;
51
52pub const COMP_NONE: u32 = 0;
54
55pub const COMP_LZ4: u32 = 1;
57
58pub const COMP_LZ4_HC: u32 = 2;
60
61#[derive(Debug, Error)]
65pub enum DvplError {
66 #[error("File too small ({0} bytes)")]
68 TooSmall(usize),
69
70 #[error("Bad magic: expected DVPL, got {}", format_magic(.0))]
72 BadMagic([u8; 4]),
73
74 #[error("Size mismatch: footer says {expected}, got {actual}")]
76 SizeMismatch { expected: usize, actual: usize },
77
78 #[error("CRC32 mismatch: expected {expected:#010x}, got {actual:#010x}")]
80 CrcMismatch { expected: u32, actual: u32 },
81
82 #[error("Decompressed size mismatch: expected {expected}, got {actual}")]
84 DecompressedSizeMismatch { expected: usize, actual: usize },
85
86 #[error("Unknown compression type: {0}")]
88 UnknownCompression(u32),
89
90 #[error("lz4 error: {0}")]
92 Lz4(String),
93}
94
95#[allow(clippy::trivially_copy_pass_by_ref)]
97fn format_magic(magic: &[u8; 4]) -> String {
98 let s: String = magic
99 .iter()
100 .map(|&b| {
101 if b.is_ascii_graphic() || b == b' ' {
102 (b as char).to_string()
103 } else {
104 format!("\\x{b:02x}")
105 }
106 })
107 .collect();
108 format!("b\"{s}\"")
109}
110
111fn crc32(data: &[u8]) -> u32 {
113 let mut hasher = Hasher::new();
114 hasher.update(data);
115 hasher.finalize()
116}
117
118fn read_u32_le(buf: &[u8], offset: usize) -> u32 {
120 u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
121}
122
123pub fn decode(data: &[u8]) -> Result<Vec<u8>, DvplError> {
144 if data.len() < FOOTER_SIZE {
145 return Err(DvplError::TooSmall(data.len()));
146 }
147
148 let footer = &data[data.len() - FOOTER_SIZE..];
149 let original_size = read_u32_le(footer, 0) as usize;
150 let compressed_size = read_u32_le(footer, 4) as usize;
151 let checksum = read_u32_le(footer, 8);
152 let comp_type = read_u32_le(footer, 12);
153
154 let magic: [u8; 4] = footer[16..20].try_into().unwrap();
155 if magic != *MAGIC {
156 return Err(DvplError::BadMagic(magic));
157 }
158
159 let payload = &data[..data.len() - FOOTER_SIZE];
160 if payload.len() != compressed_size {
161 return Err(DvplError::SizeMismatch {
162 expected: compressed_size,
163 actual: payload.len(),
164 });
165 }
166
167 let calculated_crc = crc32(payload);
168 if calculated_crc != checksum {
169 return Err(DvplError::CrcMismatch {
170 expected: checksum,
171 actual: calculated_crc,
172 });
173 }
174
175 let result = match comp_type {
176 COMP_NONE => payload.to_vec(),
177 COMP_LZ4 | COMP_LZ4_HC => lz4::block::decompress(payload, Some(original_size as i32))
178 .map_err(|e| DvplError::Lz4(e.to_string()))?,
179 _ => return Err(DvplError::UnknownCompression(comp_type)),
180 };
181
182 if result.len() != original_size {
183 return Err(DvplError::DecompressedSizeMismatch {
184 expected: original_size,
185 actual: result.len(),
186 });
187 }
188
189 Ok(result)
190}
191
192pub fn encode(data: &[u8], comp_type: u32) -> Result<Vec<u8>, DvplError> {
215 let payload = match comp_type {
216 COMP_NONE => data.to_vec(),
217 COMP_LZ4 => {
218 lz4::block::compress(data, None, false).map_err(|e| DvplError::Lz4(e.to_string()))?
219 }
220 COMP_LZ4_HC => {
221 lz4::block::compress(
223 data,
224 Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
225 false,
226 )
227 .map_err(|e| DvplError::Lz4(e.to_string()))?
228 }
229 _ => return Err(DvplError::UnknownCompression(comp_type)),
230 };
231
232 let checksum = crc32(&payload);
233 let original_size = data.len() as u32;
234 let compressed_size = payload.len() as u32;
235
236 let mut result = Vec::with_capacity(payload.len() + FOOTER_SIZE);
237 result.extend_from_slice(&payload);
238 result.extend_from_slice(&original_size.to_le_bytes());
239 result.extend_from_slice(&compressed_size.to_le_bytes());
240 result.extend_from_slice(&checksum.to_le_bytes());
241 result.extend_from_slice(&comp_type.to_le_bytes());
242 result.extend_from_slice(MAGIC);
243
244 Ok(result)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 const SAMPLE: &[u8] = b"Hello World of Tanks Blitz!";
252
253 #[test]
254 fn round_trip_none() {
255 let encoded = encode(SAMPLE, COMP_NONE).unwrap();
256 assert_eq!(decode(&encoded).unwrap(), SAMPLE);
257 }
258
259 #[test]
260 fn round_trip_lz4() {
261 let encoded = encode(SAMPLE, COMP_LZ4).unwrap();
262 assert_eq!(decode(&encoded).unwrap(), SAMPLE);
263 }
264
265 #[test]
266 fn round_trip_lz4_hc() {
267 let encoded = encode(SAMPLE, COMP_LZ4_HC).unwrap();
268 assert_eq!(decode(&encoded).unwrap(), SAMPLE);
269 }
270
271 #[test]
272 fn round_trip_empty() {
273 let encoded = encode(b"", COMP_LZ4_HC).unwrap();
274 assert_eq!(decode(&encoded).unwrap(), b"");
275 }
276
277 #[test]
278 fn footer_has_magic() {
279 let encoded = encode(SAMPLE, COMP_NONE).unwrap();
280 assert_eq!(&encoded[encoded.len() - 4..], b"DVPL");
281 }
282
283 #[test]
284 fn too_small() {
285 assert!(matches!(decode(b"short"), Err(DvplError::TooSmall(5))));
286 }
287
288 #[test]
289 fn bad_magic() {
290 let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
291 let len = blob.len();
292 blob[len - 1] = b'X';
293 assert!(matches!(decode(&blob), Err(DvplError::BadMagic(_))));
294 }
295
296 #[test]
297 fn crc_mismatch() {
298 let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
299 blob[0] ^= 0xFF;
300 assert!(matches!(decode(&blob), Err(DvplError::CrcMismatch { .. })));
301 }
302
303 #[test]
304 fn unknown_compression() {
305 assert!(matches!(
306 encode(SAMPLE, 99),
307 Err(DvplError::UnknownCompression(99))
308 ));
309 }
310
311 #[test]
312 fn size_mismatch() {
313 let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
314 let footer = blob[blob.len() - FOOTER_SIZE..].to_vec();
315 blob.truncate(blob.len() - FOOTER_SIZE - 1);
316 blob.extend_from_slice(&footer);
317 assert!(matches!(decode(&blob), Err(DvplError::SizeMismatch { .. })));
318 }
319}