archmeld 0.1.4

Secure, memory-safe, type-safe CLI for multi-format archive extraction, inspection and decompression
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Compact Pro archive reading (inspired by `cyco/cpt-rs`).
//!
//! Parses `.cpt` (Compact Pro) archive headers and entry metadata.
//! Compact Pro is a classic Mac OS archive format using RLE and LZH compression.
//!
//! # File Format
//!
//! The archive starts with an 8-byte header:
//! - Byte 0: File identifier (always `0x01`)
//! - Byte 1: Volume number (`0x01` for single-volume)
//! - Bytes 2–3: Cross-volume magic number
//! - Bytes 4–7: Offset to file/directory headers from start of file
//!
//! The header area (at the specified offset) contains:
//! - Bytes 0–3: CRC-32 of the header
//! - Bytes 4–5: Total number of files and directories
//! - Byte 6: Comment length
//! - Bytes 7–N: Comment text

use crate::error::{Error, Result};

/// Compact Pro archive header.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptHeader {
    /// Volume number.
    pub volume_number: u8,
    /// Cross-volume magic.
    pub cross_volume_magic: u16,
    /// Offset to the entry headers from the start of the file.
    pub header_offset: u32,
    /// CRC-32 of the header area.
    pub header_crc32: u32,
    /// Total number of files and directories.
    pub total_entries: u16,
    /// Archive comment (if any).
    pub comment: Option<String>,
}

/// A file entry in a Compact Pro archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptFileEntry {
    /// File name.
    pub name: String,
    /// Volume number.
    pub volume_number: u8,
    /// Offset to file data from start of archive.
    pub data_offset: u32,
    /// Mac OS file type (4-char code).
    pub file_type: String,
    /// Mac OS creator code (4-char code).
    pub creator_code: String,
    /// Resource fork uncompressed size.
    pub rsrc_uncompressed_size: u32,
    /// Data fork uncompressed size.
    pub data_uncompressed_size: u32,
    /// Resource fork compressed size.
    pub rsrc_compressed_size: u32,
    /// Data fork compressed size.
    pub data_compressed_size: u32,
    /// Whether resource fork uses LZH compression.
    pub rsrc_lzh: bool,
    /// Whether data fork uses LZH compression.
    pub data_lzh: bool,
    /// Whether the file is encrypted.
    pub is_encrypted: bool,
    /// CRC-32 of uncompressed data (data + resource forks concatenated).
    pub crc32: u32,
}

/// A directory entry in a Compact Pro archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptDirEntry {
    /// Directory name.
    pub name: String,
    /// Total number of files and directories inside (including subdirs).
    pub total_children: u16,
}

/// A parsed entry (file or directory).
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type")]
pub enum CptEntry {
    File(CptFileEntry),
    Directory(CptDirEntry),
}

impl CptEntry {
    /// Get the entry name.
    #[must_use]
    #[allow(dead_code)]
    pub fn name(&self) -> &str {
        match self {
            Self::File(f) => &f.name,
            Self::Directory(d) => &d.name,
        }
    }

    /// Whether this entry is a directory.
    #[must_use]
    #[allow(dead_code)]
    pub const fn is_directory(&self) -> bool {
        matches!(self, Self::Directory(_))
    }
}

/// Complete Compact Pro archive analysis.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptAnalysis {
    pub header: CptHeader,
    pub entries: Vec<CptEntry>,
}

/// Compact Pro file identifier byte.
const CPT_IDENTIFIER: u8 = 0x01;

/// Probe whether data looks like a Compact Pro archive.
#[must_use]
pub fn probe(data: &[u8]) -> bool {
    // First byte must be 0x01, second byte volume number (usually 0x01)
    data.len() >= 8
        && data.first().copied() == Some(CPT_IDENTIFIER)
        && data.get(1).copied() == Some(0x01)
}

/// Parse a Compact Pro archive and extract metadata.
///
/// # Errors
///
/// Returns error if the archive header is invalid.
pub fn analyze(data: &[u8]) -> Result<CptAnalysis> {
    if !probe(data) {
        return Err(Error::InvalidCompactPro("not a Compact Pro archive".into()));
    }

    let header = parse_header(data)?;
    let entries = parse_entries(data, &header)?;

    Ok(CptAnalysis { header, entries })
}

/// Verify the CRC-32 of the header area.
///
/// # Errors
///
/// Returns error on CRC mismatch.
pub fn verify(data: &[u8]) -> Result<bool> {
    let header = parse_header(data)?;
    let offset = header.header_offset as usize;

    if offset + 6 >= data.len() {
        return Err(Error::InvalidCompactPro(
            "header offset out of bounds".into(),
        ));
    }

    // CRC-32 covers bytes after the CRC field itself
    let crc_start = offset + 4;
    let crc_data = data
        .get(crc_start..)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
    let computed = crc32fast::hash(crc_data);

    Ok(computed == header.header_crc32)
}

/// Parse the Compact Pro archive header.
///
/// # Errors
///
/// Returns an error if the data is too short or the header offset is invalid.
fn parse_header(data: &[u8]) -> Result<CptHeader> {
    if data.len() < 8 {
        return Err(Error::InvalidCompactPro("data too short for header".into()));
    }

    let volume_number = data
        .get(1)
        .copied()
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
    let cvm_bytes: [u8; 2] = data
        .get(2..4)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let cross_volume_magic = u16::from_be_bytes(cvm_bytes);
    let ho_bytes: [u8; 4] = data
        .get(4..8)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let header_offset = u32::from_be_bytes(ho_bytes);

    let offset = header_offset as usize;
    if offset + 7 > data.len() {
        return Err(Error::InvalidCompactPro(
            "header offset points beyond file end".into(),
        ));
    }

    let crc_bytes: [u8; 4] = data
        .get(offset..offset + 4)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let header_crc32 = u32::from_be_bytes(crc_bytes);
    let te_bytes: [u8; 2] = data
        .get(offset + 4..offset + 6)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let total_entries = u16::from_be_bytes(te_bytes);
    let comment_len =
        data.get(offset + 6)
            .copied()
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))? as usize;

    let comment = if comment_len > 0 {
        let comment_bytes = data
            .get(offset + 7..offset + 7 + comment_len)
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
        Some(String::from_utf8_lossy(comment_bytes).into_owned())
    } else {
        None
    };

    Ok(CptHeader {
        volume_number,
        cross_volume_magic,
        header_offset,
        header_crc32,
        total_entries,
        comment,
    })
}

/// Parse Compact Pro archive entries from the data following the header.
///
/// # Errors
///
/// Returns an error if entry data is truncated or malformed.
// rust-doctor: acknowledged — binary format parser with sequential field extraction
fn parse_entries(data: &[u8], header: &CptHeader) -> Result<Vec<CptEntry>> {
    let offset = header.header_offset as usize;
    let comment_len = data.get(offset + 6).copied().unwrap_or(0) as usize;
    let mut pos = offset + 7 + comment_len;
    let mut entries = Vec::new();

    for _ in 0..header.total_entries {
        if pos >= data.len() {
            break;
        }

        let name_len_and_type = data
            .get(pos)
            .copied()
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
        let is_directory = (name_len_and_type & 0x80) != 0;
        let name_len = (name_len_and_type & 0x7F) as usize;

        if name_len == 0 || pos + 1 + name_len > data.len() {
            break;
        }

        let name_bytes = data
            .get(pos + 1..pos + 1 + name_len)
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
        let name = String::from_utf8_lossy(name_bytes).into_owned();
        pos += 1 + name_len;

        if is_directory {
            // Directory: 2 bytes for total children count
            if pos + 2 > data.len() {
                break;
            }
            let tc_bytes: [u8; 2] = data
                .get(pos..pos + 2)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let total_children = u16::from_be_bytes(tc_bytes);
            pos += 2;
            entries.push(CptEntry::Directory(CptDirEntry {
                name,
                total_children,
            }));
        } else {
            // File entry: variable fields after name
            if pos + 46 > data.len() {
                break;
            }

            let volume_number = data
                .get(pos)
                .copied()
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
            pos += 1;
            let data_off_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let data_offset = u32::from_be_bytes(data_off_raw);
            pos += 4;
            let ftype_raw = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
            let file_type = String::from_utf8_lossy(ftype_raw).into_owned();
            pos += 4;
            let creator_raw = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
            let creator_code = String::from_utf8_lossy(creator_raw).into_owned();
            pos += 4;
            // Skip creation/modification dates and finder flags (14 bytes)
            pos += 14;
            let checksum_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let crc32 = u32::from_be_bytes(checksum_raw);
            pos += 4;
            let flags_raw: [u8; 2] = data
                .get(pos..pos + 2)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let flags = u16::from_be_bytes(flags_raw);
            pos += 2;
            let is_encrypted = (flags & 0x01) != 0;
            let rsrc_lzh = (flags & 0x02) != 0;
            let data_lzh = (flags & 0x04) != 0;

            let rsrc_uncomp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let rsrc_uncompressed_size = u32::from_be_bytes(rsrc_uncomp_raw);
            pos += 4;
            let data_uncomp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let data_uncompressed_size = u32::from_be_bytes(data_uncomp_raw);
            pos += 4;
            let rsrc_comp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let rsrc_compressed_size = u32::from_be_bytes(rsrc_comp_raw);
            pos += 4;
            let data_comp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let data_compressed_size = u32::from_be_bytes(data_comp_raw);
            pos += 4;

            entries.push(CptEntry::File(CptFileEntry {
                name,
                volume_number,
                data_offset,
                file_type,
                creator_code,
                rsrc_uncompressed_size,
                data_uncompressed_size,
                rsrc_compressed_size,
                data_compressed_size,
                rsrc_lzh,
                data_lzh,
                is_encrypted,
                crc32,
            }));
        }
    }

    Ok(entries)
}

#[cfg(test)]
#[allow(clippy::missing_panics_doc)]
mod tests {
    use super::*;

    fn make_minimal_cpt() -> Vec<u8> {
        let mut data = vec![0u8; 256];
        data[0] = 0x01; // identifier
        data[1] = 0x01; // volume
        data[2] = 0x00; // cross-volume magic high
        data[3] = 0x00; // cross-volume magic low

        // Header offset = 100
        let offset: u32 = 100;
        data[4..8].copy_from_slice(&offset.to_be_bytes());

        // At offset 100: header area
        let o = 100;
        data[o..o + 4].copy_from_slice(&0u32.to_be_bytes()); // CRC
        data[o + 4..o + 6].copy_from_slice(&0u16.to_be_bytes()); // 0 entries
        data[o + 6] = 0; // no comment

        data
    }

    #[test]
    fn test_probe_valid() {
        let data = make_minimal_cpt();
        assert!(probe(&data));
    }

    #[test]
    fn test_probe_invalid() {
        let data = [0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08];
        assert!(!probe(&data));
    }

    #[test]
    fn test_analyze_minimal() {
        let data = make_minimal_cpt();
        let result = analyze(&data);
        assert!(result.is_ok());
        let analysis = result.expect("should parse");
        assert_eq!(analysis.header.total_entries, 0);
        assert!(analysis.entries.is_empty());
    }

    #[test]
    fn test_analyze_not_cpt() {
        let data = b"PK\x03\x04notcpt";
        assert!(analyze(data).is_err());
    }

    #[test]
    fn test_cpt_entry_name() {
        let dir = CptEntry::Directory(CptDirEntry {
            name: "TestDir".into(),
            total_children: 3,
        });
        assert_eq!(dir.name(), "TestDir");
        assert!(dir.is_directory());
    }
}