use crate::error::{Error, Result};
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptHeader {
pub volume_number: u8,
pub cross_volume_magic: u16,
pub header_offset: u32,
pub header_crc32: u32,
pub total_entries: u16,
pub comment: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptFileEntry {
pub name: String,
pub volume_number: u8,
pub data_offset: u32,
pub file_type: String,
pub creator_code: String,
pub rsrc_uncompressed_size: u32,
pub data_uncompressed_size: u32,
pub rsrc_compressed_size: u32,
pub data_compressed_size: u32,
pub rsrc_lzh: bool,
pub data_lzh: bool,
pub is_encrypted: bool,
pub crc32: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptDirEntry {
pub name: String,
pub total_children: u16,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type")]
pub enum CptEntry {
File(CptFileEntry),
Directory(CptDirEntry),
}
impl CptEntry {
#[must_use]
#[allow(dead_code)]
pub fn name(&self) -> &str {
match self {
Self::File(f) => &f.name,
Self::Directory(d) => &d.name,
}
}
#[must_use]
#[allow(dead_code)]
pub const fn is_directory(&self) -> bool {
matches!(self, Self::Directory(_))
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptAnalysis {
pub header: CptHeader,
pub entries: Vec<CptEntry>,
}
const CPT_IDENTIFIER: u8 = 0x01;
#[must_use]
pub fn probe(data: &[u8]) -> bool {
data.len() >= 8
&& data.first().copied() == Some(CPT_IDENTIFIER)
&& data.get(1).copied() == Some(0x01)
}
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 })
}
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(),
));
}
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)
}
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,
})
}
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 {
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 {
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;
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; data[1] = 0x01; data[2] = 0x00; data[3] = 0x00;
let offset: u32 = 100;
data[4..8].copy_from_slice(&offset.to_be_bytes());
let o = 100;
data[o..o + 4].copy_from_slice(&0u32.to_be_bytes()); data[o + 4..o + 6].copy_from_slice(&0u16.to_be_bytes()); data[o + 6] = 0;
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());
}
}