use std::io::Read;
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
#[derive(Debug, Clone, serde::Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct GzipHeader {
pub compression_method: u8,
pub compression_method_name: String,
pub flags: u8,
pub is_text: bool,
pub has_header_crc: bool,
pub has_extra: bool,
pub has_name: bool,
pub has_comment: bool,
pub mtime: u32,
pub mtime_formatted: String,
pub extra_flags: u8,
pub extra_flags_description: String,
pub os_code: u8,
pub os_name: String,
pub original_name: Option<String>,
pub comment: Option<String>,
pub extra_data: Option<Vec<u8>>,
pub header_crc16: Option<u16>,
pub header_size: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct GzipTrailer {
pub crc32: u32,
pub original_size: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct GzipAnalysis {
pub header: GzipHeader,
pub trailer: Option<GzipTrailer>,
pub compressed_size: u64,
pub file_size: u64,
pub sha256: String,
pub member_count: u32,
pub is_multi_member: bool,
}
const GZIP_MAGIC: [u8; 2] = [0x1F, 0x8B];
const FTEXT: u8 = 0x01;
const FHCRC: u8 = 0x02;
const FEXTRA: u8 = 0x04;
const FNAME: u8 = 0x08;
const FCOMMENT: u8 = 0x10;
pub fn inspect(data: &[u8]) -> Result<GzipAnalysis> {
let header = parse_header(data)?;
let trailer = parse_trailer(data);
let file_size = data.len() as u64;
let compressed_size = file_size.saturating_sub(header.header_size as u64 + 8);
let (member_count, is_multi_member) = count_members(data);
let mut hasher = Sha256::new();
hasher.update(data);
let sha256 = hex::encode(hasher.finalize());
Ok(GzipAnalysis {
header,
trailer,
compressed_size,
file_size,
sha256,
member_count,
is_multi_member,
})
}
pub fn parse_header(data: &[u8]) -> Result<GzipHeader> {
if data.len() < 10 {
return Err(Error::InvalidGzipHeader(
"data too short (< 10 bytes)".into(),
));
}
let b0 = data
.first()
.copied()
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
let b1 = data
.get(1)
.copied()
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
if b0 != GZIP_MAGIC[0] || b1 != GZIP_MAGIC[1] {
return Err(Error::InvalidGzipHeader(format!(
"invalid magic: 0x{b0:02X}{b1:02X}, expected 0x1F8B",
)));
}
let compression_method = data
.get(2)
.copied()
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
let compression_method_name = if compression_method == 8 {
"deflate".to_string()
} else {
format!("unknown ({compression_method})")
};
let flags = data
.get(3)
.copied()
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
let is_text = (flags & FTEXT) != 0;
let has_header_crc = (flags & FHCRC) != 0;
let has_extra = (flags & FEXTRA) != 0;
let has_name = (flags & FNAME) != 0;
let has_comment = (flags & FCOMMENT) != 0;
let mtime_bytes: [u8; 4] = data
.get(4..8)
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
.try_into()
.map_err(|_| Error::InvalidGzipHeader("data truncated".into()))?;
let mtime = u32::from_le_bytes(mtime_bytes);
let mtime_formatted = format_mtime(mtime);
let extra_flags = data
.get(8)
.copied()
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
let extra_flags_description = match extra_flags {
2 => "maximum compression (slowest)".into(),
4 => "fastest compression".into(),
_ => format!("unknown ({extra_flags})"),
};
let os_code = data
.get(9)
.copied()
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
let os_name = os_code_name(os_code).to_string();
let mut offset = 10;
let extra_data = if has_extra {
let xlen_bytes: [u8; 2] = data
.get(offset..offset + 2)
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
.try_into()
.map_err(|_| Error::InvalidGzipHeader("data truncated".into()))?;
let xlen = u16::from_le_bytes(xlen_bytes) as usize;
offset += 2;
let extra = data
.get(offset..offset + xlen)
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
.to_vec();
offset += xlen;
Some(extra)
} else {
None
};
let original_name = if has_name {
let (name, new_offset) = read_null_terminated(data, offset)?;
offset = new_offset;
Some(name)
} else {
None
};
let comment = if has_comment {
let (cmt, new_offset) = read_null_terminated(data, offset)?;
offset = new_offset;
Some(cmt)
} else {
None
};
let header_crc16 = if has_header_crc {
let crc_bytes: [u8; 2] = data
.get(offset..offset + 2)
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
.try_into()
.map_err(|_| Error::InvalidGzipHeader("data truncated".into()))?;
let crc = u16::from_le_bytes(crc_bytes);
offset += 2;
Some(crc)
} else {
None
};
Ok(GzipHeader {
compression_method,
compression_method_name,
flags,
is_text,
has_header_crc,
has_extra,
has_name,
has_comment,
mtime,
mtime_formatted,
extra_flags,
extra_flags_description,
os_code,
os_name,
original_name,
comment,
extra_data,
header_crc16,
header_size: offset,
})
}
fn parse_trailer(data: &[u8]) -> Option<GzipTrailer> {
if data.len() < 18 {
return None;
}
let tlen = data.len();
let crc_bytes: [u8; 4] = data.get(tlen - 8..tlen - 4)?.try_into().ok()?;
let size_bytes: [u8; 4] = data.get(tlen - 4..tlen)?.try_into().ok()?;
let crc32 = u32::from_le_bytes(crc_bytes);
let original_size = u32::from_le_bytes(size_bytes);
Some(GzipTrailer {
crc32,
original_size,
})
}
fn count_members(data: &[u8]) -> (u32, bool) {
let mut count = 0u32;
let mut decoder = flate2::read::MultiGzDecoder::new(data);
let mut buf = [0u8; 8192];
loop {
match decoder.read(&mut buf) {
Ok(0) => break,
Ok(_) => {
},
Err(_) => break,
}
}
let mut pos = 0;
while let Some(&[b0, b1]) = data
.get(pos..pos + 2)
.and_then(|s| <&[u8; 2]>::try_from(s).ok())
{
if b0 == 0x1F && b1 == 0x8B {
count += 1;
pos += 10; } else {
pos += 1;
}
}
let is_multi = count > 1;
(count.max(1), is_multi)
}
pub fn verify_crc(data: &[u8]) -> Result<bool> {
let mut decoder = flate2::read::GzDecoder::new(data);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
let computed_crc = crc32fast::hash(&decompressed);
let trailer = parse_trailer(data);
if let Some(t) = trailer {
Ok(computed_crc == t.crc32)
} else {
Ok(true) }
}
fn read_null_terminated(data: &[u8], start: usize) -> Result<(String, usize)> {
let remainder = data
.get(start..)
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
let end = remainder
.iter()
.position(|&b| b == 0)
.map(|p| start + p)
.ok_or_else(|| Error::InvalidGzipHeader("unterminated string".into()))?;
let s = String::from_utf8_lossy(
data.get(start..end)
.ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?,
)
.into_owned();
Ok((s, end + 1))
}
fn format_mtime(mtime: u32) -> String {
if mtime == 0 {
return "not set".into();
}
chrono::DateTime::from_timestamp(i64::from(mtime), 0).map_or_else(
|| format!("{mtime} (invalid)"),
|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
)
}
const fn os_code_name(code: u8) -> &'static str {
match code {
0 => "FAT filesystem (MS-DOS, OS/2, NT/Win32)",
1 => "Amiga",
2 => "VMS (or OpenVMS)",
3 => "Unix",
4 => "VM/CMS",
5 => "Atari TOS",
6 => "HPFS filesystem (OS/2, NT)",
7 => "Macintosh",
8 => "Z-System",
9 => "CP/M",
10 => "TOPS-20",
11 => "NTFS filesystem (NT)",
12 => "QDOS",
13 => "Acorn RISCOS",
255 => "Unknown",
_ => "Unrecognized",
}
}
#[cfg(test)]
#[allow(clippy::missing_panics_doc)]
mod tests {
use super::*;
fn make_gzip_data() -> Vec<u8> {
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, b"Hello, gzip inspector!").ok();
encoder.finish().expect("gzip finish failed")
}
#[test]
fn test_parse_header_valid() {
let data = make_gzip_data();
let header = parse_header(&data).expect("parse failed");
assert_eq!(header.compression_method, 8);
assert_eq!(header.compression_method_name, "deflate");
}
#[test]
fn test_parse_header_invalid_magic() {
let data = [0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03];
assert!(parse_header(&data).is_err());
}
#[test]
fn test_parse_header_too_short() {
let data = [0x1F, 0x8B];
assert!(parse_header(&data).is_err());
}
#[test]
fn test_inspect_valid() {
let data = make_gzip_data();
let analysis = inspect(&data).expect("inspect failed");
assert_eq!(analysis.header.compression_method, 8);
assert!(analysis.file_size > 0);
assert!(!analysis.sha256.is_empty());
}
#[test]
fn test_verify_crc() {
let data = make_gzip_data();
let valid = verify_crc(&data).expect("verify failed");
assert!(valid);
}
#[test]
fn test_os_code_names() {
assert_eq!(os_code_name(0), "FAT filesystem (MS-DOS, OS/2, NT/Win32)");
assert_eq!(os_code_name(3), "Unix");
assert_eq!(os_code_name(255), "Unknown");
}
#[test]
fn test_parse_trailer() {
let data = make_gzip_data();
let trailer = parse_trailer(&data);
assert!(trailer.is_some());
}
}