use std::{
cmp::Ordering,
fs::File,
io::{Read, Seek, SeekFrom},
};
use bitflags::bitflags;
use crate::{
error::{ChmError, Result},
format::{
ItspHeader, PMGI_HEADER_LEN, PMGL_HEADER_LEN, PmglEntry, parse_pmgi, parse_pmgi_entry, parse_pmgl,
parse_pmgl_entry,
},
};
const MAX_INDEX_DEPTH: u32 = 64;
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EntrySel: u8 {
const NORMAL = 0x01;
const SPECIAL = 0x02;
const META = 0x04;
const FILES = 0x08;
const DIRS = 0x10;
const ALL = 0x1F;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Dir,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryCategory {
Normal,
Special,
Meta,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub path: String,
pub length: u64,
pub(crate) start: u64,
pub(crate) space: u8,
pub kind: EntryKind,
pub category: EntryCategory,
}
impl Entry {
fn from_pmgl(e: PmglEntry) -> Self {
let kind = classify_kind(&e.path);
let category = classify_category(&e.path);
Self { path: e.path, length: e.length, start: e.start, space: e.space, kind, category }
}
#[must_use]
pub const fn is_dir(&self) -> bool {
matches!(self.kind, EntryKind::Dir)
}
#[must_use]
pub const fn is_file(&self) -> bool {
matches!(self.kind, EntryKind::File)
}
#[must_use]
pub const fn is_compressed(&self) -> bool {
self.space != 0
}
fn sel_bits(&self) -> EntrySel {
let kind_bit = match self.kind {
EntryKind::File => EntrySel::FILES,
EntryKind::Dir => EntrySel::DIRS,
};
let cat_bit = match self.category {
EntryCategory::Normal => EntrySel::NORMAL,
EntryCategory::Special => EntrySel::SPECIAL,
EntryCategory::Meta => EntrySel::META,
};
kind_bit | cat_bit
}
}
fn classify_kind(path: &str) -> EntryKind {
if path.ends_with('/') { EntryKind::Dir } else { EntryKind::File }
}
fn classify_category(path: &str) -> EntryCategory {
let bytes = path.as_bytes();
if bytes.first() != Some(&b'/') {
return EntryCategory::Meta;
}
match bytes.get(1) {
Some(&b'#' | &b'$') => EntryCategory::Special,
_ => EntryCategory::Normal,
}
}
fn cmp_ignore_ascii_case(a: &str, b: &str) -> Ordering {
a.bytes().map(|c| c.to_ascii_lowercase()).cmp(b.bytes().map(|c| c.to_ascii_lowercase()))
}
fn starts_with_ignore_ascii_case(path: &str, prefix: &str) -> bool {
path.len() >= prefix.len() && path.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
}
#[derive(Debug)]
pub struct Directory {
dir_offset: u64,
block_len: usize,
index_root: i32,
index_head: i32,
}
impl Directory {
pub fn new(dir_offset: u64, itsp: &ItspHeader) -> Self {
Self {
dir_offset: dir_offset + u64::from(itsp.header_len),
block_len: itsp.block_len as usize,
index_root: if itsp.index_root < 0 { itsp.index_head } else { itsp.index_root },
index_head: itsp.index_head,
}
}
fn fetch_block(&self, file: &mut File, idx: i32, buf: &mut [u8]) -> Result<()> {
if idx < 0 {
return Err(ChmError::BadPmgl);
}
let offset = self.dir_offset + u64::from(idx.cast_unsigned()) * self.block_len as u64;
file.seek(SeekFrom::Start(offset))?;
file.read_exact(buf)?;
Ok(())
}
fn used_end(&self, free_space: u32, malformed: ChmError) -> Result<usize> {
let free = usize::try_from(free_space).map_err(|_| ChmError::Overflow)?;
self.block_len.checked_sub(free).ok_or(malformed)
}
pub fn find(&self, file: &mut File, path: &str) -> Result<Entry> {
let mut buf = vec![0u8; self.block_len];
let mut cur = self.index_root;
for _ in 0..MAX_INDEX_DEPTH {
self.fetch_block(file, cur, &mut buf)?;
if buf.starts_with(b"PMGL") {
return self.scan_pmgl(&buf, path)?.ok_or_else(|| ChmError::NotFound(path.to_owned()));
}
if !buf.starts_with(b"PMGI") {
return Err(ChmError::BadPmgl);
}
cur = self.descend_pmgi(&buf, path)?;
if cur < 0 {
return Err(ChmError::NotFound(path.to_owned()));
}
}
Err(ChmError::BadPmgi)
}
fn scan_pmgl(&self, buf: &[u8], target: &str) -> Result<Option<Entry>> {
let header = parse_pmgl(buf)?;
let end = self.used_end(header.free_space, ChmError::BadPmgl)?;
let mut pos = PMGL_HEADER_LEN;
while pos < end {
let (entry, next_pos) = parse_pmgl_entry(buf, pos)?;
if entry.path.eq_ignore_ascii_case(target) {
return Ok(Some(Entry::from_pmgl(entry)));
}
pos = next_pos;
}
Ok(None)
}
fn descend_pmgi(&self, buf: &[u8], target: &str) -> Result<i32> {
let header = parse_pmgi(buf)?;
let end = self.used_end(header.free_space, ChmError::BadPmgi)?;
let mut pos = PMGI_HEADER_LEN;
let mut last_child: i32 = -1;
while pos < end {
let (key, child, next_pos) = parse_pmgi_entry(buf, pos)?;
if cmp_ignore_ascii_case(&key, target) == Ordering::Greater {
return Ok(last_child);
}
last_child = child;
pos = next_pos;
}
Ok(last_child)
}
pub fn enumerate(&self, file: &mut File, prefix: Option<&str>, sel: EntrySel) -> Result<Vec<Entry>> {
let prefix = prefix.map(|p| if p.is_empty() || p.ends_with('/') { p.to_owned() } else { format!("{p}/") });
let mut entries = Vec::new();
let mut buf = vec![0u8; self.block_len];
let mut cur = self.index_head;
while cur >= 0 {
self.fetch_block(file, cur, &mut buf)?;
let header = parse_pmgl(&buf)?;
let end = self.used_end(header.free_space, ChmError::BadPmgl)?;
let mut pos = PMGL_HEADER_LEN;
while pos < end {
let (pmgl_entry, next_pos) = parse_pmgl_entry(&buf, pos)?;
pos = next_pos;
if prefix.as_deref().is_some_and(|p| !starts_with_ignore_ascii_case(&pmgl_entry.path, p)) {
continue;
}
let entry = Entry::from_pmgl(pmgl_entry);
if sel.contains(entry.sel_bits()) {
entries.push(entry);
}
}
if header.block_next >= 0 && header.block_next <= cur {
return Err(ChmError::BadPmgl);
}
cur = header.block_next;
}
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn categories_follow_the_path_prefix() {
assert_eq!(classify_category("/index.html"), EntryCategory::Normal);
assert_eq!(classify_category("/"), EntryCategory::Normal);
assert_eq!(classify_category("/#IDXHDR"), EntryCategory::Special);
assert_eq!(classify_category("/$OBJINST"), EntryCategory::Special);
assert_eq!(classify_category("::DataSpace/NameList"), EntryCategory::Meta);
assert_eq!(classify_category(""), EntryCategory::Meta);
}
#[test]
fn kind_follows_the_trailing_slash() {
assert_eq!(classify_kind("/a/b.html"), EntryKind::File);
assert_eq!(classify_kind("/a/"), EntryKind::Dir);
assert_eq!(classify_kind(""), EntryKind::File);
}
fn entry(path: &str) -> Entry {
Entry::from_pmgl(PmglEntry { path: path.to_owned(), space: 0, start: 0, length: 0 })
}
#[test]
fn selector_requires_both_a_category_and_a_kind() {
let file = entry("/index.html");
assert!(EntrySel::ALL.contains(file.sel_bits()));
assert!((EntrySel::NORMAL | EntrySel::FILES).contains(file.sel_bits()));
assert!(!EntrySel::NORMAL.contains(file.sel_bits()));
assert!(!EntrySel::FILES.contains(file.sel_bits()));
assert!(!(EntrySel::NORMAL | EntrySel::DIRS).contains(file.sel_bits()));
}
#[test]
fn selector_bits_cover_every_category_and_kind() {
assert_eq!(entry("/a.html").sel_bits(), EntrySel::NORMAL | EntrySel::FILES);
assert_eq!(entry("/a/").sel_bits(), EntrySel::NORMAL | EntrySel::DIRS);
assert_eq!(entry("/#SYSTEM").sel_bits(), EntrySel::SPECIAL | EntrySel::FILES);
assert_eq!(entry("::DataSpace/NameList").sel_bits(), EntrySel::META | EntrySel::FILES);
assert_eq!(entry("::DataSpace/").sel_bits(), EntrySel::META | EntrySel::DIRS);
}
#[test]
fn entry_predicates_match_kind_and_space() {
let dir = entry("/a/");
assert!(dir.is_dir() && !dir.is_file());
let file = entry("/a.html");
assert!(file.is_file() && !file.is_dir());
assert!(!file.is_compressed());
let compressed = Entry::from_pmgl(PmglEntry { path: "/a".into(), space: 1, start: 0, length: 0 });
assert!(compressed.is_compressed());
}
#[test]
fn case_insensitive_ordering_matches_a_lowercased_comparison() {
assert_eq!(cmp_ignore_ascii_case("ABC", "abc"), Ordering::Equal);
assert_eq!(cmp_ignore_ascii_case("/Index.HTML", "/index.html"), Ordering::Equal);
assert_eq!(cmp_ignore_ascii_case("/a", "/b"), Ordering::Less);
assert_eq!(cmp_ignore_ascii_case("/B", "/a"), Ordering::Greater);
assert_eq!(cmp_ignore_ascii_case("/a", "/ab"), Ordering::Less);
assert_eq!(cmp_ignore_ascii_case("_", "A"), Ordering::Less);
assert_eq!(cmp_ignore_ascii_case("_", "a"), Ordering::Less);
}
#[test]
fn case_insensitive_prefix_matching() {
assert!(starts_with_ignore_ascii_case("/Docs/a.html", "/docs/"));
assert!(starts_with_ignore_ascii_case("/docs/", "/docs/"));
assert!(starts_with_ignore_ascii_case("/anything", ""));
assert!(!starts_with_ignore_ascii_case("/docs", "/docs/"));
assert!(!starts_with_ignore_ascii_case("/other/a.html", "/docs/"));
}
}