Skip to main content

crush_core/
decompression.rs

1//! Decompression functionality
2//!
3//! Provides the public `decompress()` API that reads Crush-compressed data,
4//! validates headers and checksums, routes to the correct plugin, and decompresses.
5
6use crate::error::{PluginError, Result, ValidationError};
7use crate::plugin::registry::get_plugin_by_magic;
8use crate::plugin::{list_plugins, CrushHeader, FileMetadata};
9use crc32fast::Hasher;
10use std::sync::atomic::AtomicBool;
11use std::sync::Arc;
12
13/// Read and compute the CRC32 block that follows a Crush header.
14///
15/// Returns `(stored_crc, computed_crc, new_payload_start)` where:
16/// - `stored_crc` is the checksum recorded in the stream
17/// - `computed_crc` is the checksum computed over the remaining payload
18/// - `new_payload_start` is the byte offset at which the actual payload begins
19///
20/// # Errors
21///
22/// Returns an error if the input is truncated (fewer than 4 bytes remain at
23/// `payload_start`).
24pub(crate) fn read_crc32_block(input: &[u8], payload_start: usize) -> Result<(u32, u32, usize)> {
25    if input.len() < payload_start + 4 {
26        return Err(ValidationError::InvalidHeader(
27            "Truncated: CRC32 flag set but no CRC32 data".to_string(),
28        )
29        .into());
30    }
31    let stored_crc = u32::from_le_bytes([
32        input[payload_start],
33        input[payload_start + 1],
34        input[payload_start + 2],
35        input[payload_start + 3],
36    ]);
37    let new_payload_start = payload_start + 4;
38
39    let payload_for_crc = &input[new_payload_start..];
40    let mut hasher = Hasher::new();
41    hasher.update(payload_for_crc);
42    let computed_crc = hasher.finalize();
43
44    Ok((stored_crc, computed_crc, new_payload_start))
45}
46
47#[derive(Debug)]
48pub struct DecompressionResult {
49    pub data: Vec<u8>,
50    pub metadata: FileMetadata,
51}
52
53/// Decompress Crush-compressed data
54///
55/// Reads the Crush header to identify the compression plugin, validates the CRC32
56/// checksum (if present), and decompresses the data using the appropriate plugin.
57///
58/// # Errors
59///
60/// Returns an error if:
61/// - Input data is too short (less than 16-byte header)
62/// - Header magic number is invalid
63/// - Header version is unsupported
64/// - Required plugin is not registered
65/// - CRC32 checksum validation fails
66/// - Decompression operation fails
67///
68/// # Examples
69///
70/// ```
71/// use crush_core::{init_plugins, compress, decompress};
72///
73/// init_plugins().expect("Plugin initialization failed");
74/// let data = b"Hello, world!";
75/// let compressed = compress(data).expect("Compression failed");
76/// let decompressed = decompress(&compressed).expect("Decompression failed");
77/// assert_eq!(data.as_slice(), decompressed.data.as_slice());
78/// ```
79pub fn decompress(input: &[u8]) -> Result<DecompressionResult> {
80    let cancel_flag = Arc::new(AtomicBool::new(false));
81    decompress_with_cancel(input, cancel_flag)
82}
83
84/// Decompress Crush-compressed data with an externally-controlled cancellation flag.
85///
86/// This variant accepts a caller-provided `cancel_flag` so that signal handlers
87/// (e.g. Ctrl+C) can interrupt long-running GPU kernel dispatches.
88///
89/// # Errors
90///
91/// Returns an error if:
92/// - Input data is too short (less than 16-byte header)
93/// - Header magic number is invalid
94/// - Header version is unsupported
95/// - Required plugin is not registered
96/// - CRC32 checksum validation fails
97/// - Decompression operation fails
98/// - The cancel flag is set during decompression
99pub fn decompress_with_cancel(
100    input: &[u8],
101    cancel_flag: Arc<AtomicBool>,
102) -> Result<DecompressionResult> {
103    // Validate minimum size (header + CRC32 if present)
104    if input.len() < CrushHeader::SIZE {
105        return Err(ValidationError::InvalidHeader(format!(
106            "Input too short: {} bytes, expected at least {}",
107            input.len(),
108            CrushHeader::SIZE
109        ))
110        .into());
111    }
112
113    // Parse header
114    let header_bytes: [u8; CrushHeader::SIZE] = input[0..CrushHeader::SIZE]
115        .try_into()
116        .map_err(|_| ValidationError::InvalidHeader("Failed to read header".to_string()))?;
117    let header = CrushHeader::from_bytes(&header_bytes)?;
118
119    let mut payload_start = CrushHeader::SIZE;
120
121    // Handle CRC32
122    if header.has_crc32() {
123        let (stored_crc, computed_crc, new_start) = read_crc32_block(input, payload_start)?;
124        payload_start = new_start;
125        if stored_crc != computed_crc {
126            return Err(ValidationError::CrcMismatch {
127                expected: stored_crc,
128                actual: computed_crc,
129            }
130            .into());
131        }
132    }
133
134    // Handle metadata
135    let metadata = if header.has_metadata() {
136        if input.len() < payload_start + 2 {
137            return Err(ValidationError::InvalidHeader(
138                "Truncated: metadata flag set but no metadata length".to_string(),
139            )
140            .into());
141        }
142        let metadata_len =
143            u16::from_le_bytes([input[payload_start], input[payload_start + 1]]) as usize;
144        payload_start += 2;
145
146        if input.len() < payload_start + metadata_len {
147            return Err(ValidationError::InvalidHeader(
148                "Truncated: metadata length exceeds payload size".to_string(),
149            )
150            .into());
151        }
152        let metadata_bytes = &input[payload_start..payload_start + metadata_len];
153        payload_start += metadata_len;
154
155        FileMetadata::from_bytes(metadata_bytes)?
156    } else {
157        FileMetadata::default()
158    };
159
160    let compressed_payload = &input[payload_start..];
161
162    // Find plugin by magic number from registry
163    let plugin = get_plugin_by_magic(header.magic).ok_or_else(|| {
164        let available = list_plugins()
165            .iter()
166            .map(|p| p.name)
167            .collect::<Vec<_>>()
168            .join(", ");
169
170        PluginError::NotFound(format!(
171            "No plugin found for magic number {:02X?}. \
172             Available plugins: {}. \
173             Did you call init_plugins()?",
174            header.magic, available
175        ))
176    })?;
177
178    // Decompress the payload using the caller-provided cancel flag
179    let decompressed = plugin.decompress(compressed_payload, cancel_flag)?;
180
181    // Validate decompressed size matches header
182    let expected_size = usize::try_from(header.original_size).map_err(|_| {
183        ValidationError::InvalidHeader("Original size exceeds platform limits".to_string())
184    })?;
185
186    if decompressed.len() != expected_size {
187        return Err(ValidationError::CorruptedData(format!(
188            "Size mismatch: header says {} bytes, got {} bytes",
189            header.original_size,
190            decompressed.len()
191        ))
192        .into());
193    }
194
195    Ok(DecompressionResult {
196        data: decompressed,
197        metadata,
198    })
199}
200
201#[cfg(test)]
202#[allow(clippy::expect_used)]
203#[allow(clippy::unwrap_used)]
204#[allow(clippy::unreadable_literal)]
205mod tests {
206    use super::*;
207    use crate::{compress, init_plugins};
208
209    #[test]
210    #[allow(clippy::unwrap_used)]
211    fn test_decompress_valid() {
212        init_plugins().unwrap();
213        let original = b"Test data for decompression";
214        let compressed = compress(original).unwrap();
215        let decompressed = decompress(&compressed).unwrap().data;
216
217        assert_eq!(original.as_slice(), decompressed.as_slice());
218    }
219
220    #[test]
221    fn test_decompress_truncated() {
222        let truncated = &[0x43, 0x52, 0x01, 0x00, 0x01]; // Only 5 bytes
223
224        let result = decompress(truncated);
225        assert!(result.is_err());
226    }
227
228    #[test]
229    fn test_decompress_invalid_magic() {
230        let mut invalid = vec![0xFF, 0xFF, 0xFF, 0xFF]; // Bad magic
231        invalid.extend_from_slice(&[0u8; 12]); // Rest of header
232
233        let result = decompress(&invalid);
234        assert!(result.is_err());
235    }
236
237    #[test]
238    #[allow(clippy::unwrap_used)]
239    fn test_decompress_corrupted_crc() {
240        init_plugins().unwrap();
241        let original = b"Data to corrupt";
242        let mut compressed = compress(original).unwrap();
243
244        // Corrupt the CRC32 (bytes 16-19)
245        if compressed.len() > 16 {
246            compressed[16] ^= 0xFF;
247        }
248
249        let result = decompress(&compressed);
250        assert!(result.is_err());
251    }
252
253    #[test]
254    fn test_decompress_with_metadata() {
255        use crate::plugin::FileMetadata;
256        use crate::{compress_with_options, CompressionOptions};
257
258        init_plugins().expect("Failed to init");
259        let original = b"Data with metadata";
260        let metadata = FileMetadata {
261            mtime: Some(1234567890),
262            #[cfg(unix)]
263            permissions: Some(0o644),
264        };
265        let options = CompressionOptions::default().with_file_metadata(metadata.clone());
266        let compressed = compress_with_options(original, &options).expect("Compression failed");
267
268        let result = decompress(&compressed).expect("Decompression failed");
269
270        assert_eq!(original.as_slice(), result.data.as_slice());
271        assert_eq!(result.metadata.mtime, metadata.mtime);
272        #[cfg(unix)]
273        assert_eq!(result.metadata.permissions, metadata.permissions);
274    }
275
276    #[test]
277    fn test_decompress_truncated_crc32() {
278        init_plugins().expect("Failed to init");
279        let original = b"Test";
280        let mut compressed = compress(original).expect("Compression failed");
281
282        // Truncate to remove CRC32 bytes
283        compressed.truncate(CrushHeader::SIZE); // Just header, no CRC32
284
285        let result = decompress(&compressed);
286        assert!(result.is_err()); // Should error about missing CRC32
287    }
288
289    #[test]
290    fn test_decompress_truncated_metadata_length() {
291        use crate::plugin::FileMetadata;
292        use crate::{compress_with_options, CompressionOptions};
293
294        init_plugins().expect("Failed to init");
295        let original = b"Test";
296        let metadata = FileMetadata {
297            mtime: Some(1234567890),
298            #[cfg(unix)]
299            permissions: Some(0o755),
300        };
301        let options = CompressionOptions::default().with_file_metadata(metadata);
302        let mut compressed = compress_with_options(original, &options).expect("Compression failed");
303
304        // Truncate after header + CRC32 to cut metadata length field
305        let truncate_pos = CrushHeader::SIZE + 4 + 1;
306        if compressed.len() > truncate_pos {
307            compressed.truncate(truncate_pos);
308        }
309
310        let result = decompress(&compressed);
311        assert!(result.is_err());
312    }
313
314    #[test]
315    fn test_decompress_truncated_metadata_payload() {
316        use crate::plugin::FileMetadata;
317        use crate::{compress_with_options, CompressionOptions};
318
319        init_plugins().expect("Failed to init");
320        let original = b"Test";
321        let metadata = FileMetadata {
322            mtime: Some(1234567890),
323            #[cfg(unix)]
324            permissions: Some(0o755),
325        };
326        let options = CompressionOptions::default().with_file_metadata(metadata);
327        let mut compressed = compress_with_options(original, &options).expect("Compression failed");
328
329        // Truncate in middle of metadata payload
330        let truncate_pos = CrushHeader::SIZE + 4 + 2 + 3;
331        if compressed.len() > truncate_pos {
332            compressed.truncate(truncate_pos);
333        }
334
335        let result = decompress(&compressed);
336        assert!(result.is_err());
337    }
338
339    #[test]
340    fn test_decompress_plugin_not_found() {
341        // Create a valid header but with a magic number for a non-existent plugin
342        let mut fake_compressed = vec![
343            0x43, 0x52, 0x01, 0xFF, // Magic: CR01 but invalid plugin (0xFF)
344            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Original size: 0
345            0x00, // Flags: 0 (no CRC, no metadata)
346            0x00, 0x00, 0x00, // Reserved
347        ];
348        // Add some fake compressed data
349        fake_compressed.extend_from_slice(&[0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]);
350
351        let result = decompress(&fake_compressed);
352        assert!(result.is_err());
353        // Error could be about plugin not found or invalid magic
354        // Just verify it fails, the exact error depends on implementation details
355    }
356
357    #[test]
358    fn test_decompress_empty_input() {
359        let result = decompress(&[]);
360        assert!(result.is_err());
361        assert!(result.unwrap_err().to_string().contains("too short"));
362    }
363
364    #[test]
365    fn test_decompress_default_metadata() {
366        init_plugins().expect("Failed to init");
367        let original = b"No metadata test";
368        let compressed = compress(original).expect("Compression failed");
369
370        let result = decompress(&compressed).expect("Decompression failed");
371
372        // Should have default (empty) metadata when none was provided
373        assert!(result.metadata.mtime.is_none());
374        #[cfg(unix)]
375        assert!(result.metadata.permissions.is_none());
376    }
377
378    #[test]
379    #[allow(clippy::unwrap_used)]
380    fn test_decompress_corrupted_payload() {
381        init_plugins().unwrap();
382        let original = b"Data to corrupt";
383        let mut compressed = compress(original).unwrap();
384
385        // Corrupt a byte in the payload (after header + CRC32)
386        if compressed.len() > 24 {
387            compressed[24] ^= 0xFF;
388        }
389
390        let result = decompress(&compressed);
391        // Should fail either in decompression or size validation
392        assert!(result.is_err());
393    }
394}