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