heddle_format/compression/
mod.rs1#![deny(clippy::cast_possible_truncation)]
3
4#[cfg(feature = "zstd")]
11use std::io::Read;
12
13const COMPRESSED_HEADER_LEN: usize = 9;
14const MAX_DECOMPRESSED_SIZE: u64 = 256 * 1024 * 1024;
15const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[repr(u8)]
20enum CompressionType {
21 Zstd = 1,
23}
24
25impl CompressionType {
26 fn from_u8(value: u8) -> Option<Self> {
28 match value {
29 1 => Some(CompressionType::Zstd),
30 _ => None,
31 }
32 }
33}
34
35#[derive(Debug, Clone, Copy)]
37pub struct CompressionConfig {
38 pub enabled: bool,
40 pub level: i32,
43 pub min_size: usize,
45 pub max_delta_size: usize,
47}
48
49impl Default for CompressionConfig {
50 fn default() -> Self {
51 Self {
52 enabled: cfg!(feature = "zstd"),
53 level: 3, min_size: 256, max_delta_size: 10_000_000, }
57 }
58}
59
60impl CompressionConfig {
61 pub fn from_env() -> Self {
63 let mut config = Self::default();
64
65 if let Ok(val) = std::env::var("HEDDLE_COMPRESSION") {
66 let requested = val != "0" && val.to_lowercase() != "false";
67 config.enabled = requested && cfg!(feature = "zstd");
68 }
69
70 if let Ok(val) = std::env::var("HEDDLE_COMPRESSION_LEVEL")
71 && let Ok(level) = val.parse::<i32>()
72 {
73 config.level = level.clamp(1, 22);
74 }
75
76 if let Ok(val) = std::env::var("HEDDLE_COMPRESSION_MIN_SIZE")
77 && let Ok(size) = val.parse::<usize>()
78 {
79 config.min_size = size;
80 }
81
82 config
83 }
84
85 pub fn disabled() -> Self {
87 Self {
88 enabled: false,
89 level: 0,
90 min_size: usize::MAX,
91 max_delta_size: 0,
92 }
93 }
94}
95
96#[derive(Debug, thiserror::Error)]
98pub enum CompressionError {
99 #[error("decompression failed: {0}")]
100 DecompressionFailed(String),
101 #[error("compression failed: {0}")]
102 CompressionFailed(String),
103 #[error("invalid compression type: {0}")]
104 InvalidType(u8),
105 #[error("corrupted data: {0}")]
106 CorruptedData(String),
107 #[error("invalid operation: {0}")]
108 InvalidOperation(String),
109 #[error("object size {size} exceeds maximum {max}")]
110 SizeLimitExceeded { size: u64, max: u64 },
111}
112
113#[cfg(feature = "zstd")]
114fn compress_zstd_impl(data: &[u8], level: i32) -> Result<Vec<u8>, CompressionError> {
116 zstd::encode_all(data, level).map_err(|e| CompressionError::CompressionFailed(e.to_string()))
117}
118
119#[cfg(not(feature = "zstd"))]
120fn compress_zstd_impl(_data: &[u8], _level: i32) -> Result<Vec<u8>, CompressionError> {
121 Err(CompressionError::InvalidOperation(
122 "zstd compression support not compiled into this build".to_string(),
123 ))
124}
125
126#[cfg(feature = "bench")]
127pub fn compress_zstd(data: &[u8], level: i32) -> Result<Vec<u8>, CompressionError> {
129 compress_zstd_impl(data, level)
130}
131
132#[cfg(feature = "zstd")]
133fn decompress_zstd_impl(data: &[u8], expected_size: u64) -> Result<Vec<u8>, CompressionError> {
135 validate_size(expected_size)?;
136 let expected_capacity = checked_size_to_usize("zstd expected size", expected_size)?;
137
138 let mut decoder = zstd::stream::read::Decoder::new(data)
139 .map_err(|e| CompressionError::DecompressionFailed(e.to_string()))?;
140 let mut decompressed = Vec::with_capacity(expected_capacity);
141 let mut buffer = [0u8; 8192];
142
143 loop {
144 let bytes_read = decoder
145 .read(&mut buffer)
146 .map_err(|e| CompressionError::DecompressionFailed(e.to_string()))?;
147 if bytes_read == 0 {
148 break;
149 }
150
151 let next_size = decompressed.len().checked_add(bytes_read).ok_or_else(|| {
152 CompressionError::CorruptedData("decompressed size overflows".to_string())
153 })?;
154 let next_size = u64::try_from(next_size).map_err(|_| {
155 CompressionError::CorruptedData("decompressed size exceeds platform limits".to_string())
156 })?;
157 if next_size > expected_size {
158 return Err(CompressionError::CorruptedData(format!(
159 "decompressed size exceeds recorded header size: expected {expected_size}, got at least {next_size}",
160 )));
161 }
162
163 decompressed.extend_from_slice(&buffer[..bytes_read]);
164 }
165
166 Ok(decompressed)
167}
168
169#[cfg(not(feature = "zstd"))]
170fn decompress_zstd_impl(_data: &[u8], expected_size: u64) -> Result<Vec<u8>, CompressionError> {
171 validate_size(expected_size)?;
172 Err(CompressionError::InvalidOperation(
173 "zstd-compressed data is unsupported in this build".to_string(),
174 ))
175}
176
177#[cfg(feature = "bench")]
178pub fn decompress_zstd(data: &[u8], expected_size: u64) -> Result<Vec<u8>, CompressionError> {
180 decompress_zstd_impl(data, expected_size)
181}
182
183pub fn compress(
188 data: &[u8],
189 config: &CompressionConfig,
190) -> Result<Option<Vec<u8>>, CompressionError> {
191 if !config.enabled || data.len() < config.min_size {
192 return Ok(None);
193 }
194
195 validate_size(data.len() as u64)?;
196
197 let compressed = compress_zstd_impl(data, config.level)?;
199
200 if compressed.len() >= data.len() {
202 return Ok(None);
203 }
204
205 let mut result = Vec::with_capacity(COMPRESSED_HEADER_LEN + compressed.len());
207 result.push(CompressionType::Zstd as u8);
208 result.extend_from_slice(&(data.len() as u64).to_be_bytes());
209 result.extend_from_slice(&compressed);
210
211 Ok(Some(result))
212}
213
214pub fn decompress(data: &[u8]) -> Result<Vec<u8>, CompressionError> {
218 if data.len() < COMPRESSED_HEADER_LEN {
219 return Ok(data.to_vec());
221 }
222
223 let compression_type =
224 CompressionType::from_u8(data[0]).ok_or_else(|| CompressionError::InvalidType(data[0]))?;
225
226 match compression_type {
227 CompressionType::Zstd if zstd_header_len(data).is_some() => {
228 decompress_zstd_with_header(data)
229 }
230 CompressionType::Zstd => Ok(data.to_vec()),
231 }
232}
233
234pub fn is_compressed(data: &[u8]) -> bool {
236 if data.len() < COMPRESSED_HEADER_LEN {
237 return false;
238 }
239
240 matches!(
241 CompressionType::from_u8(data[0]),
242 Some(CompressionType::Zstd)
243 ) && zstd_header_len(data).is_some()
244}
245
246pub fn header_uncompressed_size(data: &[u8]) -> Option<u64> {
254 if data.len() < COMPRESSED_HEADER_LEN {
255 return None;
256 }
257 let CompressionType::Zstd = CompressionType::from_u8(data[0])?;
258 zstd_header_len(data)?;
259 Some(u64::from_be_bytes(
260 data[1..COMPRESSED_HEADER_LEN].try_into().ok()?,
261 ))
262}
263
264#[cfg(test)]
265fn compression_info(data: &[u8]) -> Option<(CompressionType, u64)> {
267 if data.len() < COMPRESSED_HEADER_LEN {
268 return None;
269 }
270
271 let compression_type = CompressionType::from_u8(data[0])?;
272 let uncompressed_size = u64::from_be_bytes(data[1..COMPRESSED_HEADER_LEN].try_into().ok()?);
273
274 Some((compression_type, uncompressed_size))
275}
276
277fn decompress_zstd_with_header(data: &[u8]) -> Result<Vec<u8>, CompressionError> {
278 try_decompress_zstd(data, COMPRESSED_HEADER_LEN, read_u64_size)
279}
280
281fn zstd_header_len(data: &[u8]) -> Option<usize> {
282 if has_magic_at(data, COMPRESSED_HEADER_LEN, ZSTD_MAGIC) {
283 Some(COMPRESSED_HEADER_LEN)
284 } else {
285 None
286 }
287}
288
289fn try_decompress_zstd<F>(
290 data: &[u8],
291 header_len: usize,
292 read_size: F,
293) -> Result<Vec<u8>, CompressionError>
294where
295 F: Fn(&[u8]) -> Result<u64, CompressionError>,
296{
297 let uncompressed_size = read_size(data)?;
298 let decompressed = decompress_zstd_impl(&data[header_len..], uncompressed_size)?;
299 validate_decompressed_len(uncompressed_size, decompressed.len())?;
300 Ok(decompressed)
301}
302
303fn read_u64_size(data: &[u8]) -> Result<u64, CompressionError> {
304 if data.len() < COMPRESSED_HEADER_LEN {
305 return Err(CompressionError::CorruptedData(
306 "compression header truncated".to_string(),
307 ));
308 }
309
310 let recorded_size =
311 u64::from_be_bytes(data[1..COMPRESSED_HEADER_LEN].try_into().map_err(|_| {
312 CompressionError::CorruptedData("compression header truncated".to_string())
313 })?);
314 validate_size(recorded_size)?;
315 Ok(recorded_size)
316}
317
318fn validate_size(size: u64) -> Result<(), CompressionError> {
319 if size > MAX_DECOMPRESSED_SIZE {
320 return Err(CompressionError::SizeLimitExceeded {
321 size,
322 max: MAX_DECOMPRESSED_SIZE,
323 });
324 }
325
326 Ok(())
327}
328
329#[cfg(feature = "zstd")]
330fn checked_size_to_usize(field: &str, size: u64) -> Result<usize, CompressionError> {
331 usize::try_from(size)
332 .map_err(|_| CompressionError::CorruptedData(format!("{field} exceeds platform limits")))
333}
334
335fn validate_decompressed_len(expected: u64, actual: usize) -> Result<(), CompressionError> {
336 if actual as u64 != expected {
337 return Err(CompressionError::CorruptedData(format!(
338 "decompressed size mismatch: expected {expected}, got {actual}",
339 )));
340 }
341
342 Ok(())
343}
344
345fn has_magic_at(data: &[u8], offset: usize, magic: [u8; 4]) -> bool {
346 data.get(offset..offset + magic.len()) == Some(magic.as_slice())
347}
348
349#[cfg(test)]
350mod compression_tests;