use crate::error::{Error, Result, ValidationError};
use crate::spec::{
CENTRAL_DIR_HEADER_SIG, COMPRESSION_METHOD_DEFLATE, DOS_DATE, DOS_TIME,
END_OF_CENTRAL_DIR_SIG, GENERAL_PURPOSE_FLAG,
};
use crc32fast::Hasher;
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
pub torrentzip_crc32: Option<u32>,
pub computed_crc32: u32,
pub file_count: usize,
pub files: Vec<String>,
}
pub struct TorrentZipValidator;
impl TorrentZipValidator {
pub fn validate(data: &[u8]) -> Result<ValidationResult> {
let mut errors = Vec::new();
let mut files = Vec::new();
if data.len() < 22 {
return Ok(ValidationResult {
is_valid: false,
errors: vec![ValidationError::InvalidComment],
torrentzip_crc32: None,
computed_crc32: 0,
file_count: 0,
files: vec![],
});
}
let eocd_offset = find_eocd(&data)?;
let (cd_offset, cd_size, comment) = parse_eocd(&data, eocd_offset)?;
let torrentzip_crc32 = parse_torrentzip_comment(&comment);
if torrentzip_crc32.is_none() {
errors.push(ValidationError::InvalidComment);
}
let computed_crc32 = compute_cd_crc32(&data, cd_offset, cd_size);
if let Some(expected_crc) = torrentzip_crc32 {
if expected_crc != computed_crc32 {
errors.push(ValidationError::CommentCrcMismatch {
expected: expected_crc,
actual: computed_crc32,
});
}
}
let mut prev_name_lower: Option<String> = None;
let mut pos = cd_offset as usize;
let cd_end = (cd_offset + cd_size) as usize;
while pos < cd_end {
let (name, entry_size, validation_errs) =
parse_central_dir_entry(&data, pos)?;
files.push(name.clone());
errors.extend(validation_errs);
let name_lower = name.to_lowercase();
if let Some(prev) = &prev_name_lower {
if &name_lower < prev {
errors.push(ValidationError::FilesNotSorted(
prev.clone(),
name_lower.clone(),
));
}
}
prev_name_lower = Some(name_lower);
pos += entry_size;
}
let is_valid = errors.is_empty();
Ok(ValidationResult {
is_valid,
errors,
torrentzip_crc32,
computed_crc32,
file_count: files.len(),
files,
})
}
}
fn find_eocd(data: &[u8]) -> Result<usize> {
let sig: [u8; 4] = END_OF_CENTRAL_DIR_SIG.to_le_bytes();
let max_comment = 65535usize;
let search_start = data.len().saturating_sub(22 + max_comment);
for i in search_start..data.len().saturating_sub(21) {
if data[i..i + 4] == sig {
return Ok(i);
}
}
Err(Error::InvalidZip("EOCD not found".to_string()))
}
fn parse_eocd(data: &[u8], offset: usize) -> Result<(u32, u32, Vec<u8>)> {
if offset + 22 > data.len() {
return Err(Error::InvalidZip("EOCD truncated".to_string()));
}
let cd_size = u32::from_le_bytes([data[offset + 12], data[offset + 13], data[offset + 14], data[offset + 15]]);
let cd_offset = u32::from_le_bytes([data[offset + 16], data[offset + 17], data[offset + 18], data[offset + 19]]);
let comment_len = u16::from_le_bytes([data[offset + 20], data[offset + 21]]) as usize;
let comment = if offset + 22 + comment_len <= data.len() {
data[offset + 22..offset + 22 + comment_len].to_vec()
} else {
vec![]
};
Ok((cd_offset, cd_size, comment))
}
fn parse_torrentzip_comment(comment: &[u8]) -> Option<u32> {
let prefix = b"TORRENTZIPPED-";
if comment.len() != 22 {
return None;
}
if !comment.starts_with(prefix) {
return None;
}
let hex_str = std::str::from_utf8(&comment[14..22]).ok()?;
u32::from_str_radix(hex_str, 16).ok()
}
fn compute_cd_crc32(data: &[u8], offset: u32, size: u32) -> u32 {
let start = offset as usize;
let end = start + size as usize;
if end > data.len() {
return 0;
}
let mut hasher = Hasher::new();
hasher.update(&data[start..end]);
hasher.finalize()
}
fn parse_central_dir_entry(
data: &[u8],
offset: usize,
) -> Result<(String, usize, Vec<ValidationError>)> {
let mut errors = Vec::new();
if offset + 46 > data.len() {
return Err(Error::InvalidZip("Central directory entry truncated".to_string()));
}
let sig = u32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]);
if sig != CENTRAL_DIR_HEADER_SIG {
return Err(Error::InvalidZip("Invalid central directory signature".to_string()));
}
let compression = u16::from_le_bytes([data[offset + 10], data[offset + 11]]);
let mod_time = u16::from_le_bytes([data[offset + 12], data[offset + 13]]);
let mod_date = u16::from_le_bytes([data[offset + 14], data[offset + 15]]);
let filename_len = u16::from_le_bytes([data[offset + 28], data[offset + 29]]) as usize;
let extra_len = u16::from_le_bytes([data[offset + 30], data[offset + 31]]) as usize;
let comment_len = u16::from_le_bytes([data[offset + 32], data[offset + 33]]) as usize;
let general_flag = u16::from_le_bytes([data[offset + 8], data[offset + 9]]);
if compression != COMPRESSION_METHOD_DEFLATE {
errors.push(ValidationError::WrongCompressionMethod(compression));
}
if general_flag != GENERAL_PURPOSE_FLAG {
errors.push(ValidationError::WrongGeneralFlag(general_flag));
}
if mod_time != DOS_TIME || mod_date != DOS_DATE {
errors.push(ValidationError::WrongTimestamp);
}
if extra_len > 0 {
errors.push(ValidationError::ExtraDataPresent);
}
if comment_len > 0 {
errors.push(ValidationError::FileCommentsPresent);
}
let name_start = offset + 46;
let name_end = name_start + filename_len;
if name_end > data.len() {
return Err(Error::InvalidZip("Filename truncated".to_string()));
}
let filename = String::from_utf8_lossy(&data[name_start..name_end]).to_string();
let entry_size = 46 + filename_len + extra_len + comment_len;
Ok((filename, entry_size, errors))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_torrentzip_comment() {
let comment = b"TORRENTZIPPED-F175FDED";
assert_eq!(parse_torrentzip_comment(comment), Some(0xF175FDED));
let bad_comment = b"NOT A TORRENTZIP";
assert_eq!(parse_torrentzip_comment(bad_comment), None);
let wrong_len = b"TORRENTZIPPED-123";
assert_eq!(parse_torrentzip_comment(wrong_len), None);
}
}