zipatch-rs 1.0.0

Parser for FFXIV ZiPatch patch files
Documentation
use binrw::{BinRead, BinResult, Endian};
use std::io::Cursor;

use super::SqpackFile;

/// Which `SqPack` file kind a `SqpkHeader` targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetFileKind {
    /// A `.datN` data file.
    Dat,
    /// A `.indexN` index file.
    Index,
}

/// Which header slot a `SqpkHeader` writes into.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetHeaderKind {
    /// Version header at file offset 0.
    Version,
    /// Index header at file offset 1024.
    Index,
    /// Data header at file offset 1024.
    Data,
}

/// Resolved file target for a `SqpkHeader`, parameterised by `TargetFileKind`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SqpkHeaderTarget {
    /// Header writes into a `.dat` file.
    Dat(SqpackFile),
    /// Header writes into a `.index` file.
    Index(SqpackFile),
}

fn read_file_kind<R: std::io::Read + std::io::Seek>(
    reader: &mut R,
    _: Endian,
    (): (),
) -> BinResult<TargetFileKind> {
    let byte = <u8 as BinRead>::read_options(reader, Endian::Big, ())?;
    match byte {
        b'D' => Ok(TargetFileKind::Dat),
        b'I' => Ok(TargetFileKind::Index),
        _ => Err(binrw::Error::Custom {
            pos: 0,
            err: Box::new(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "unknown SqpkHeader file kind",
            )),
        }),
    }
}

fn read_header_kind<R: std::io::Read + std::io::Seek>(
    reader: &mut R,
    _: Endian,
    (): (),
) -> BinResult<TargetHeaderKind> {
    let byte = <u8 as BinRead>::read_options(reader, Endian::Big, ())?;
    match byte {
        b'V' => Ok(TargetHeaderKind::Version),
        b'I' => Ok(TargetHeaderKind::Index),
        b'D' => Ok(TargetHeaderKind::Data),
        _ => Err(binrw::Error::Custom {
            pos: 0,
            err: Box::new(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "unknown SqpkHeader header kind",
            )),
        }),
    }
}

fn read_header_target<R: std::io::Read + std::io::Seek>(
    reader: &mut R,
    endian: Endian,
    (file_kind,): (&TargetFileKind,),
) -> BinResult<SqpkHeaderTarget> {
    let f = SqpackFile::read_options(reader, endian, ())?;
    match file_kind {
        TargetFileKind::Dat => Ok(SqpkHeaderTarget::Dat(f)),
        TargetFileKind::Index => Ok(SqpkHeaderTarget::Index(f)),
    }
}

/// SQPK `H` command body: write a 1024-byte header into a `SqPack` file.
#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
#[br(big)]
pub struct SqpkHeader {
    /// Whether the target is a `.dat` or `.index` file.
    #[br(parse_with = read_file_kind)]
    pub file_kind: TargetFileKind,
    /// Which header slot to write into.
    #[br(parse_with = read_header_kind)]
    pub header_kind: TargetHeaderKind,
    /// Resolved target file, tagged by `file_kind`.
    #[br(pad_before = 1, parse_with = read_header_target, args(&file_kind))]
    pub target: SqpkHeaderTarget,
    /// Exactly 1024 bytes of header data.
    #[br(count = 1024)]
    pub header_data: Vec<u8>,
}

pub(crate) fn parse(body: &[u8]) -> crate::Result<SqpkHeader> {
    Ok(SqpkHeader::read_be(&mut Cursor::new(body))?)
}

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

    #[test]
    fn parses_header_dat_version() {
        let mut body = Vec::new();
        body.push(b'D'); // file_kind = Dat
        body.push(b'V'); // header_kind = Version
        body.push(0u8); // alignment
        body.extend_from_slice(&10u16.to_be_bytes()); // main_id
        body.extend_from_slice(&20u16.to_be_bytes()); // sub_id
        body.extend_from_slice(&0u32.to_be_bytes()); // file_id
        body.extend_from_slice(&[0xCCu8; 1024]); // header_data

        let cmd = parse(&body).unwrap();
        assert!(matches!(cmd.file_kind, TargetFileKind::Dat));
        assert!(matches!(cmd.header_kind, TargetHeaderKind::Version));
        match cmd.target {
            SqpkHeaderTarget::Dat(f) => {
                assert_eq!(f.main_id, 10);
                assert_eq!(f.sub_id, 20);
            }
            other => panic!("expected SqpkHeaderTarget::Dat, got {other:?}"),
        }
        assert_eq!(cmd.header_data.len(), 1024);
    }

    #[test]
    fn rejects_unknown_file_kind() {
        let mut body = Vec::new();
        body.push(b'Z'); // invalid
        body.push(b'V');
        body.push(0u8);
        body.extend_from_slice(&[0u8; 8 + 1024]);
        assert!(parse(&body).is_err());
    }

    #[test]
    fn rejects_unknown_header_kind() {
        let mut body = Vec::new();
        body.push(b'D');
        body.push(b'Z'); // invalid header_kind
        body.push(0u8);
        body.extend_from_slice(&[0u8; 8 + 1024]);
        assert!(parse(&body).is_err());
    }

    #[test]
    fn parses_header_index_file() {
        let mut body = Vec::new();
        body.push(b'I'); // file_kind = Index
        body.push(b'I'); // header_kind = Index
        body.push(0u8);
        body.extend_from_slice(&7u16.to_be_bytes()); // main_id
        body.extend_from_slice(&8u16.to_be_bytes()); // sub_id
        body.extend_from_slice(&0u32.to_be_bytes()); // file_id
        body.extend_from_slice(&[0xBBu8; 1024]);

        let cmd = parse(&body).unwrap();
        assert!(matches!(cmd.file_kind, TargetFileKind::Index));
        assert!(matches!(cmd.header_kind, TargetHeaderKind::Index));
        match cmd.target {
            SqpkHeaderTarget::Index(f) => {
                assert_eq!(f.main_id, 7);
                assert_eq!(f.sub_id, 8);
            }
            other => panic!("expected SqpkHeaderTarget::Index, got {other:?}"),
        }
        assert_eq!(cmd.header_data.len(), 1024);
    }

    #[test]
    fn header_data_truncated() {
        let mut body = Vec::new();
        body.push(b'D');
        body.push(b'V');
        body.push(0u8);
        body.extend_from_slice(&[0u8; 8]);
        body.extend_from_slice(&[0u8; 512]); // only 512, need 1024
        assert!(parse(&body).is_err());
    }
}