chdlady-core 0.1.0

Core container manipulation for CHD v5 format
//! Linked-list metadata storage, iteration, and overall SHA-1 computation.
use crate::error::ChdError;
use crate::header::ChdHeader;
use sha1::{Digest, Sha1};
use std::io::{Read, Seek, SeekFrom, Write};

/// Flag indicating that this metadata entry is included in overall SHA-1.
pub const CHD_MDFLAGS_CHECKSUM: u8 = 0x01;

/// A single metadata node from the linked list in a CHD container.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetadataEntry {
    /// 4-byte FourCC metadata tag.
    pub metatag: u32,
    /// Metadata flags.
    pub flags: u8,
    /// Absolute file offset of the entry header.
    pub offset: u64,
    /// Absolute file offset of the next entry header (0 if last).
    pub next: u64,
    /// Payload bytes of the metadata.
    pub value: Vec<u8>,
}

impl MetadataEntry {
    /// Returns the 4-byte FourCC tag as an ASCII string.
    pub fn tag_string(&self) -> String {
        let bytes = self.metatag.to_be_bytes();
        String::from_utf8_lossy(&bytes).to_string()
    }

    /// Returns true if this metadata entry is checksummed into the overall SHA-1.
    pub fn is_checksummed(&self) -> bool {
        (self.flags & CHD_MDFLAGS_CHECKSUM) != 0
    }
}

/// Maximum allowed single metadata entry size (16 MiB).
pub const MAX_METADATA_SIZE: usize = 16 * 1024 * 1024;

/// Metadata header size in bytes.
pub const CHD_METADATA_HEADER_SIZE: usize = 16;

/// Iterator navigating the singly-linked list of metadata entries.
pub struct MetadataIterator<'a, R: Read + Seek> {
    reader: &'a mut R,
    current_offset: u64,
    visited_offsets: std::collections::HashSet<u64>,
    file_len: Option<u64>,
}

impl<'a, R: Read + Seek> MetadataIterator<'a, R> {
    /// Creates a new MetadataIterator starting at the given file offset.
    pub fn new(reader: &'a mut R, meta_offset: u64) -> Self {
        Self {
            reader,
            current_offset: meta_offset,
            visited_offsets: std::collections::HashSet::new(),
            file_len: None,
        }
    }
}

impl<'a, R: Read + Seek> Iterator for MetadataIterator<'a, R> {
    type Item = Result<MetadataEntry, ChdError>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current_offset == 0 {
            return None;
        }

        let offset = self.current_offset;
        if !self.visited_offsets.insert(offset) {
            self.current_offset = 0;
            return Some(Err(ChdError::InvalidData(format!(
                "circular metadata linked list detected at offset {}",
                offset
            ))));
        }

        let file_len = match self.file_len {
            Some(len) => len,
            None => match self.reader.seek(SeekFrom::End(0)) {
                Ok(len) => {
                    self.file_len = Some(len);
                    len
                }
                Err(e) => {
                    self.current_offset = 0;
                    return Some(Err(ChdError::Io(e)));
                }
            },
        };

        if offset + 16 > file_len {
            self.current_offset = 0;
            return Some(Err(ChdError::InvalidData(format!(
                "metadata header at offset {} exceeds file length {}",
                offset, file_len
            ))));
        }

        if let Err(e) = self.reader.seek(SeekFrom::Start(offset)) {
            self.current_offset = 0;
            return Some(Err(ChdError::Io(e)));
        }

        let mut header_buf = [0u8; CHD_METADATA_HEADER_SIZE];
        if let Err(e) = self.reader.read_exact(&mut header_buf) {
            self.current_offset = 0;
            return Some(Err(ChdError::Io(e)));
        }

        let metatag =
            u32::from_be_bytes([header_buf[0], header_buf[1], header_buf[2], header_buf[3]]);
        let flags = header_buf[4];
        let length = u32::from_be_bytes([0, header_buf[5], header_buf[6], header_buf[7]]) as usize;
        let next = u64::from_be_bytes([
            header_buf[8],
            header_buf[9],
            header_buf[10],
            header_buf[11],
            header_buf[12],
            header_buf[13],
            header_buf[14],
            header_buf[15],
        ]);

        if length > MAX_METADATA_SIZE || offset + 16 + (length as u64) > file_len {
            self.current_offset = 0;
            return Some(Err(ChdError::InvalidData(format!(
                "invalid metadata entry length {} at offset {} (file length {})",
                length, offset, file_len
            ))));
        }

        let mut value = vec![0u8; length];
        if let Err(e) = self.reader.read_exact(&mut value) {
            self.current_offset = 0;
            return Some(Err(ChdError::Io(e)));
        }

        self.current_offset = next;

        Some(Ok(MetadataEntry {
            metatag,
            flags,
            offset,
            next,
            value,
        }))
    }
}

/// Reads all metadata entries starting from `meta_offset`.
pub fn read_all_metadata<R: Read + Seek>(
    reader: &mut R,
    meta_offset: u64,
) -> Result<Vec<MetadataEntry>, ChdError> {
    MetadataIterator::new(reader, meta_offset).collect()
}

/// Computes the overall SHA-1 hash from raw data SHA-1 and checksummed metadata.
#[must_use]
pub fn compute_overall_sha1(raw_sha1: [u8; 20], metadata: &[MetadataEntry]) -> [u8; 20] {
    let mut hash_entries: Vec<[u8; 24]> = Vec::new();

    for entry in metadata {
        if entry.is_checksummed() {
            let mut hasher = Sha1::new();
            hasher.update(&entry.value);
            let val_sha1: [u8; 20] = hasher.finalize().into();

            let mut combined = [0u8; 24];
            combined[0..4].copy_from_slice(&entry.metatag.to_be_bytes());
            combined[4..24].copy_from_slice(&val_sha1);
            hash_entries.push(combined);
        }
    }

    hash_entries.sort_unstable();

    let mut overall_hasher = Sha1::new();
    overall_hasher.update(raw_sha1);
    for item in &hash_entries {
        overall_hasher.update(item);
    }
    overall_hasher.finalize().into()
}

/// Adds or replaces a metadata entry in an existing CHD v5 container and updates overall SHA-1.
pub fn write_metadata_entry<F: Read + Write + Seek>(
    file: &mut F,
    header: &mut ChdHeader,
    tag: u32,
    index: u32,
    value: &[u8],
    flags: u8,
) -> Result<(), ChdError> {
    if value.is_empty() || value.len() >= 16 * 1024 * 1024 {
        return Err(ChdError::InvalidData(
            "invalid metadata payload length".into(),
        ));
    }

    let metadata = read_all_metadata(file, header.meta_offset)?;
    let mut found_idx = None;
    let mut tag_count = 0u32;
    for (idx, entry) in metadata.iter().enumerate() {
        if entry.metatag == tag {
            if tag_count == index {
                found_idx = Some(idx);
                break;
            }
            tag_count += 1;
        }
    }

    let mut finished = false;
    if let Some(idx) = found_idx {
        let entry = &metadata[idx];
        if value.len() <= entry.value.len() {
            file.seek(SeekFrom::Start(entry.offset + 16))?;
            file.write_all(value)?;
            if value.len() != entry.value.len() {
                let val_len = (value.len() as u32).to_be_bytes();
                file.seek(SeekFrom::Start(entry.offset + 5))?;
                file.write_all(&val_len[1..4])?;
            }
            file.seek(SeekFrom::Start(entry.offset + 4))?;
            file.write_all(&[flags])?;
            finished = true;
        } else {
            // Unlink current entry
            if idx == 0 {
                header.meta_offset = entry.next;
                file.seek(SeekFrom::Start(48))?;
                file.write_all(&header.meta_offset.to_be_bytes())?;
            } else {
                let prev = &metadata[idx - 1];
                file.seek(SeekFrom::Start(prev.offset + 8))?;
                file.write_all(&entry.next.to_be_bytes())?;
            }
        }
    }

    if !finished {
        let new_offset = file.seek(SeekFrom::End(0))?;
        let mut hdr = [0u8; 16];
        hdr[0..4].copy_from_slice(&tag.to_be_bytes());
        hdr[4] = flags;
        let val_len = (value.len() as u32).to_be_bytes();
        hdr[5..8].copy_from_slice(&val_len[1..4]);
        // next is 0
        file.write_all(&hdr)?;
        file.write_all(value)?;

        let current_entries = read_all_metadata(file, header.meta_offset)?;
        if let Some(last) = current_entries.last() {
            file.seek(SeekFrom::Start(last.offset + 8))?;
            file.write_all(&new_offset.to_be_bytes())?;
        } else {
            header.meta_offset = new_offset;
            file.seek(SeekFrom::Start(48))?;
            file.write_all(&new_offset.to_be_bytes())?;
        }
    }

    let updated_metadata = read_all_metadata(file, header.meta_offset)?;
    header.sha1 = compute_overall_sha1(header.raw_sha1, &updated_metadata);
    file.seek(SeekFrom::Start(84))?;
    file.write_all(&header.sha1)?;
    file.flush()?;
    Ok(())
}

/// Deletes a metadata entry from an existing CHD v5 container and updates overall SHA-1.
pub fn delete_metadata_entry<F: Read + Write + Seek>(
    file: &mut F,
    header: &mut ChdHeader,
    tag: u32,
    index: u32,
) -> Result<(), ChdError> {
    let metadata = read_all_metadata(file, header.meta_offset)?;
    let mut found_idx = None;
    let mut tag_count = 0u32;
    for (idx, entry) in metadata.iter().enumerate() {
        if entry.metatag == tag {
            if tag_count == index {
                found_idx = Some(idx);
                break;
            }
            tag_count += 1;
        }
    }

    let idx = found_idx.ok_or(ChdError::MetadataNotFound)?;
    let entry = &metadata[idx];

    if idx == 0 {
        header.meta_offset = entry.next;
        file.seek(SeekFrom::Start(48))?;
        file.write_all(&header.meta_offset.to_be_bytes())?;
    } else {
        let prev = &metadata[idx - 1];
        file.seek(SeekFrom::Start(prev.offset + 8))?;
        file.write_all(&entry.next.to_be_bytes())?;
    }

    let updated_metadata = read_all_metadata(file, header.meta_offset)?;
    header.sha1 = compute_overall_sha1(header.raw_sha1, &updated_metadata);
    file.seek(SeekFrom::Start(84))?;
    file.write_all(&header.sha1)?;
    file.flush()?;
    Ok(())
}