macbinary-rs 0.1.0

Transparent access to MacBinary-encoded files
Documentation
#![feature(seek_stream_len)]

use std::{cmp::min, fmt, fs, io, path::Path};

use binrw::{BinReaderExt, binread};
use bitflags::bitflags;
use fourcc_rs::FourCC;
use macintosh_utils::{
    Fork, Point,
    chrono::{DateTime, Utc},
    decode_string,
};

mod reader;
pub use reader::Reader;

#[derive(Debug, Eq, PartialEq)]
pub enum Version {
    None,
    MacBinaryI,
    MacBinaryII,
    MacBinaryIII,
}

impl fmt::Display for Version {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Version::None => write!(f, "None"),
            Version::MacBinaryI => write!(f, "MacBinary I"),
            Version::MacBinaryII => write!(f, "MacBinary II"),
            Version::MacBinaryIII => write!(f, "Mac Binary III"),
        }
    }
}

#[derive(Debug, Copy, Clone, Default)]
pub struct Config {
    strat: ResourceForkDetectionStrategy,
}

#[derive(Debug, Copy, Clone, Default)]
pub enum ResourceForkDetectionStrategy {
    #[default]
    All,
    None,
    HiddenDirectory,
    NamedFork,
    Suffix,
}

#[derive(Debug)]
pub struct MacBinary<R> {
    inner: R,
    config: Config,
    header: Option<Header>,
}

impl<R> MacBinary<R> {
    pub fn into_inner(self) -> R {
        self.inner
    }

    pub fn header(&self) -> Option<&Header> {
        self.header.as_ref()
    }

    pub fn version(&self) -> Version {
        let Some(header) = self.header.as_ref() else {
            return Version::None;
        };

        if header.downloader_min_version == 0x81 {
            return Version::MacBinaryII;
        }

        if header.downloader_min_version == 0x82 {
            return Version::MacBinaryIII;
        }

        Version::MacBinaryI
    }

    pub fn creator(&self) -> FourCC {
        self.header.as_ref().map(|h| h.creator).unwrap_or_default()
    }

    pub fn type_code(&self) -> FourCC {
        self.header
            .as_ref()
            .map(|h| h.type_code)
            .unwrap_or_default()
    }
}

impl<R: io::Read + io::Seek> MacBinary<R> {
    pub fn try_new(value: R) -> Result<Self, binrw::Error> {
        Self::try_new_with_config(value, Config::default())
    }

    pub fn try_new_with_config(mut value: R, config: Config) -> Result<Self, binrw::Error> {
        let initial_position = value.stream_position()?;
        Ok(match value.read_be() {
            Ok(header) => MacBinary {
                config,
                inner: value,
                header: Some(header),
            },
            Err(_) => {
                let _ = value.seek(std::io::SeekFrom::Start(initial_position))?;
                MacBinary {
                    config,
                    inner: value,
                    header: None,
                }
            }
        })
    }

    pub fn open_fork(&mut self, fork: Fork) -> Result<Reader<&mut R>, io::Error> {
        match fork {
            Fork::Resource => {
                if let Some(header) = self.header.as_ref() {
                    let len = header.resource_fork_len as u64;
                    let position = header.resource_fork_location();
                    Ok(Reader::try_new(&mut self.inner, position, position + len)?)
                } else {
                    match self.config.strat {
                        ResourceForkDetectionStrategy::All => todo!(),
                        ResourceForkDetectionStrategy::None => {
                            Ok(Reader::try_new(&mut self.inner, 0, 0)?)
                        }
                        ResourceForkDetectionStrategy::HiddenDirectory => todo!(),
                        ResourceForkDetectionStrategy::NamedFork => todo!(),
                        ResourceForkDetectionStrategy::Suffix => todo!(),
                    }
                }
            }

            Fork::Data => {
                if let Some(header) = self.header.as_ref() {
                    let len = header.data_fork_len as u64;
                    let position = header.data_fork_location();
                    Ok(Reader::try_new(&mut self.inner, position, position + len)?)
                } else {
                    let len = self.inner.stream_len()?;
                    Ok(Reader::try_new(&mut self.inner, 0, len)?)
                }
            }
        }
    }

    pub fn data_fork_len(&mut self) -> Result<u64, io::Error> {
        match self.version() {
            Version::None => self.inner.stream_len(),
            _ => Ok(self.header.as_ref().unwrap().data_fork_len as u64),
        }
    }

    pub fn resource_fork_len(&mut self) -> Result<u64, io::Error> {
        match self.version() {
            // TODO: apply resource fork detection strategy
            Version::None => Ok(0),
            _ => Ok(self.header.as_ref().unwrap().resource_fork_len as u64),
        }
    }

    pub fn data_fork(&mut self) -> Result<Reader<&mut R>, io::Error> {
        self.open_fork(Fork::Data)
    }

    pub fn resource_fork(&mut self) -> Result<Reader<&mut R>, io::Error> {
        self.open_fork(Fork::Resource)
    }

    pub fn into_fork(self, fork: Fork) -> Result<Reader<R>, io::Error> {
        let Self {
            header,
            mut inner,
            config: _,
        } = self;

        match fork {
            Fork::Resource => {
                if let Some(header) = header {
                    let len = header.resource_fork_len as u64;
                    let position = header.resource_fork_location();

                    Ok(Reader::try_new(inner, position, position + len)?)
                } else {
                    // TODO: respect config
                    Ok(Reader::try_new(inner, 0, 0)?)
                }
            }
            Fork::Data => {
                if let Some(header) = header.as_ref() {
                    let len = header.data_fork_len as u64;
                    let position = header.data_fork_location();

                    Ok(Reader::try_new(inner, position, position + len)?)
                } else {
                    let len = inner.stream_len()?;
                    Ok(Reader::try_new(inner, 0, len)?)
                }
            }
        }
    }

    pub fn comment(&mut self) -> Result<String, io::Error> {
        if let Some(header) = self.header.as_ref()
            && header.comment_len != 0
        {
            let position = self.inner.stream_position()?;
            self.inner
                .seek(io::SeekFrom::Start(header.file_comment_location()))?;
            let mut data = vec![0u8; header.comment_len as usize];
            self.inner.read_exact(&mut data)?;

            let comment = macintosh_utils::decode_string(data);
            self.inner.seek(io::SeekFrom::Start(position))?;
            return Ok(comment);
        }

        // TODO: Consider looking for .finfo directory to achieve SheepShaver compatibility
        Ok(String::new())
    }

    pub fn into_data_fork(self) -> Result<Reader<R>, io::Error> {
        self.into_fork(Fork::Data)
    }

    pub fn into_resource_fork(self) -> Result<Reader<R>, io::Error> {
        self.into_fork(Fork::Resource)
    }
}

impl MacBinary<fs::File> {
    pub fn open(path: impl AsRef<Path>) -> Result<Self, binrw::Error> {
        MacBinary::try_new(fs::File::open(path)?)
    }
}

bitflags! {
    #[derive(Debug, Clone)]
    pub struct Flags: u8 {
        const LOCKED = 1<<0;
    }
}

#[binread]
#[derive(Debug)]
#[br(big)]
pub struct Header {
    pub version: u8,
    #[br(temp,assert(name_len > 0 && name_len < 63))]
    name_len: u8,
    #[br(map(|r: [u8; 63]| decode_string(r[0..min(name_len as usize, 63)].to_vec())))]
    pub name: String,
    pub type_code: FourCC,
    pub creator: FourCC,
    pub finder_flags_upper: u8,
    #[br(temp, assert(zero==0))]
    zero: u8,
    pub position: Point,
    pub window_id: u16,
    #[br(map(Flags::from_bits_retain))]
    pub flags: Flags,
    #[br(temp, assert(zero_again==0))]
    zero_again: u8,
    pub data_fork_len: u32,
    pub resource_fork_len: u32,
    #[br(map(macintosh_utils::date))]
    pub created_at: DateTime<Utc>,
    #[br(map(macintosh_utils::date))]
    pub modified_at: DateTime<Utc>,

    pub comment_len: u16,
    pub finder_flags_lower: u8,
    //#[br(temp, assert(magic == fourcc!("mBIN")))]
    pub magic: FourCC,
    pub file_name_script: u8,
    pub extended_finder_flags: u8,
    #[br(temp)]
    reserved_2: [u8; 8],
    pub unpacked_total_len: u32,
    pub extended_header_len: u16,
    pub uploader_version: u8,
    pub downloader_min_version: u8,
    /// xmodem crc 16
    pub checksum: u16,

    #[br(temp)]
    reserved_3: u16,
}

impl Header {
    pub const FIXED_SIZE: usize = 128;

    fn extended_header_location(&self) -> u64 {
        Header::FIXED_SIZE as u64
    }

    fn data_fork_location(&self) -> u64 {
        self.extended_header_location() + align_128(self.extended_header_len as u64)
    }

    fn resource_fork_location(&self) -> u64 {
        self.data_fork_location() + align_128(self.data_fork_len as u64)
    }

    fn file_comment_location(&self) -> u64 {
        self.resource_fork_location() + align_128(self.resource_fork_len as u64)
    }
}

fn align_128(input: u64) -> u64 {
    if (0x80 - 1) & input != 0 {
        (input + 0x80) & !(0x80 - 1)
    } else {
        input
    }
}

#[cfg(test)]
mod tests {
    use std::{
        fs::{File, exists},
        io::Read,
        path::PathBuf,
    };

    use crate::{MacBinary, align_128};
    use fourcc_rs::fourcc;

    #[test]
    fn read_macbinary_ii_header() {
        let file = open_fixture("FRED.CPT");
        let header = file.header().unwrap();
        assert_eq!(header.name, "Freddie 1.0.cpt");
        assert_eq!(header.resource_fork_len, 0);
        assert_eq!(header.data_fork_len, 303472);
        assert_eq!(header.magic, fourcc!("\0\0\0\0"));
        assert_eq!(header.uploader_version, 0x81);
        assert_eq!(header.downloader_min_version, 0x81);
    }

    #[test]
    fn read_data_fork() {
        let mut file = open_fixture("jpeg2gif.cpt");
        let header = file.header().unwrap();
        let mut buffer = vec![0u8; header.data_fork_len as usize];
        let mut data_fork = file.data_fork().unwrap();
        assert!(data_fork.read_exact(&mut buffer).is_ok());
    }

    fn open_fixture_raw(name: &'static str) -> File {
        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("test/")
            .join(name);

        if !exists(&path).unwrap() {
            panic!("Test fixture {name} does not exist!");
        }

        std::fs::File::open(path).unwrap()
    }

    fn open_fixture(name: &'static str) -> MacBinary<File> {
        let file = open_fixture_raw(name);
        MacBinary::try_new(file).unwrap()
    }

    #[test]
    fn align_int() {
        assert_eq!(align_128(0), 0);
        assert_eq!(align_128(1), 128);
        assert_eq!(align_128(127), 128);
        assert_eq!(align_128(128), 128);
        assert_eq!(align_128(129), 256);
    }
}