Skip to main content

ithmb_core/photodb/
builder.rs

1//! PhotoDB/ArtworkDB integrity checker and binary builder.
2//!
3//! Ported from `IthmbCodec.PhotoDb.Serialization` (C#).
4//!
5//! # Integrity Check
6//!
7//! `integrity_check_photodb` validates the structure of a PhotoDB/ArtworkDB
8//! binary, returning a list of issues (empty = clean).
9//!
10//! # Binary Builder
11//!
12//! `try_build_photodb` constructs a synthetic PhotoDB/ArtworkDB binary from
13//! a list of `BuildEntry` descriptors.
14#![allow(
15    clippy::similar_names,
16    clippy::doc_markdown,
17    clippy::cast_possible_truncation,
18    clippy::cast_sign_loss,
19    clippy::manual_let_else,
20    clippy::match_same_arms,
21    clippy::single_match_else,
22    clippy::cast_possible_wrap
23)]
24
25use crate::error::DecodeError;
26use crate::photodb::types::{
27    MHBA, MHFD, MHIA, MHIF, MHII, MHL, MHNI, MHOD, MHSD, is_known_magic, read_i32, read_u32, read_u32_be, read_u32_le,
28};
29use crate::profile::Profile;
30use crate::profile_db::ProfileDb;
31
32// ---------------------------------------------------------------------------
33// Supporting types
34// ---------------------------------------------------------------------------
35
36/// A single entry to be built into a synthetic PhotoDB/ArtworkDB binary.
37///
38/// Each entry specifies a format ID (matching a known profile) and its raw
39/// pixel data.
40#[derive(Debug, Clone)]
41pub struct BuildEntry {
42    /// Format/profile identifier (e.g. 1019, 1007).
43    pub format_id: i32,
44    /// Raw pixel data for this entry.
45    pub data: Vec<u8>,
46}
47
48/// An MHNI entry discovered during integrity walking.
49#[derive(Debug, Clone)]
50struct MhniEntry {
51    format_id: i32,
52    ithmb_offset: i32,
53    image_size: i32,
54}
55
56/// Mutable state threaded through the integrity walk tree.
57struct WalkState {
58    entries: Vec<MhniEntry>,
59    max_chunk_end: usize,
60    issues: Vec<String>,
61}
62
63// ---------------------------------------------------------------------------
64// Internal helpers
65// ---------------------------------------------------------------------------
66
67/// Check whether the bytes at `pos` form a valid chunk header.
68///
69/// A valid chunk has at least 8 bytes remaining (magic + header_size),
70/// a `header_size` >= 8, and a known magic constant.
71fn has_child_chunks(data: &[u8], pos: usize, end: usize, little_endian: bool) -> bool {
72    if pos + 8 > end || pos + 8 > data.len() {
73        return false;
74    }
75    let hdr_size = read_u32(data, pos + 4, little_endian);
76    if hdr_size < 8 {
77        return false;
78    }
79    let magic = read_u32(data, pos, little_endian);
80    is_known_magic(magic)
81}
82
83/// Fast check: do the first 4 bytes match the ASCII "mhfd" magic in either
84/// endianness representation?
85fn can_open_photodb(data: &[u8]) -> bool {
86    if data.len() < 4 {
87        return false;
88    }
89    // LE representation: "mhfd" = [0x6d, 0x68, 0x66, 0x64]
90    // BE representation: bytes are byte-swapped version of the canonical LE
91    // u32 value 0x6466686d → [0x64, 0x66, 0x68, 0x6d]
92    let magic_le = read_u32_le(data, 0);
93    let magic_be = read_u32_be(data, 0);
94    magic_le == MHFD || magic_be == MHFD
95}
96
97/// Detect endianness of a PhotoDB binary.
98///
99/// Returns `Some(true)` for little-endian, `Some(false)` for big-endian,
100/// and `None` if detection fails.
101fn detect_endianness(data: &[u8]) -> Option<bool> {
102    if data.len() < 8 {
103        return None;
104    }
105    let le_magic = read_u32_le(data, 0);
106    let be_magic = read_u32_be(data, 0);
107    if le_magic == MHFD {
108        // Verify the header_size field looks reasonable in LE
109        let hdr_size = read_u32_le(data, 4);
110        if hdr_size >= 12 && (hdr_size as usize) <= data.len() {
111            return Some(true);
112        }
113    }
114    if be_magic == MHFD {
115        let hdr_size = read_u32_be(data, 4);
116        if hdr_size >= 12 && (hdr_size as usize) <= data.len() {
117            return Some(false);
118        }
119    }
120    // Fallback: try to determine by checking which interpretation gives a
121    // plausible header_size.
122    if le_magic == MHFD {
123        return Some(true);
124    }
125    if be_magic == MHFD {
126        return Some(false);
127    }
128    None
129}
130
131/// Compute the total extent (end offset) of a chunk based on its magic and
132/// header fields.
133fn chunk_total_end(data: &[u8], pos: usize, magic: u32, _hdr_size: u32, little_endian: bool) -> usize {
134    let hdr_size = read_u32(data, pos + 4, little_endian) as usize;
135    match magic {
136        // MHII stores its total length at offset +8.
137        MHII => {
138            if pos + 12 <= data.len() {
139                pos + read_u32(data, pos + 8, little_endian) as usize
140            } else {
141                pos + 12
142            }
143        }
144        // MHNI: use total_len at +8 if it is larger than header_size and
145        // within bounds (handles our synthetic files with padding).
146        // For real classic files (header_size = 36, total_len undefined),
147        // falls back to header_size.
148        MHNI => {
149            if pos + 12 <= data.len() {
150                let total_len = read_u32(data, pos + 8, little_endian) as usize;
151                if total_len > hdr_size && total_len <= data.len().saturating_sub(pos) {
152                    pos + total_len
153                } else {
154                    pos + hdr_size
155                }
156            } else {
157                pos + hdr_size
158            }
159        }
160        // All other types use header_size.
161        _ => pos + hdr_size,
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Integrity walk tree (recursive)
167// ---------------------------------------------------------------------------
168
169/// Recursively walk the chunk tree, validating structure and collecting MHNI
170/// entries.
171///
172/// `offset` is the starting byte position, `end` is the exclusive upper bound
173/// for this walk level (from the parent container's `header_size` or
174/// `data.len()` for the root). `depth` prevents runaway recursion (max 64).
175#[allow(clippy::too_many_arguments)]
176fn integrity_walk_tree(
177    data: &[u8],
178    offset: usize,
179    end: usize,
180    depth: usize,
181    little_endian: bool,
182    state: &mut WalkState,
183) {
184    if depth > 64 {
185        state.issues.push("Maximum chunk nesting depth (64) exceeded".into());
186        return;
187    }
188
189    let mut pos = offset;
190    while pos < end && pos + 8 <= data.len() {
191        // Check for a valid chunk at this position.
192        if !has_child_chunks(data, pos, end, little_endian) {
193            // If we're still before `end` but no valid chunk, there may be
194            // trailing garbage — the caller handles this after the walk.
195            break;
196        }
197
198        let magic = read_u32(data, pos, little_endian);
199        let hdr_size = read_u32(data, pos + 4, little_endian) as usize;
200        let chunk_end = chunk_total_end(data, pos, magic, hdr_size as u32, little_endian);
201        state.max_chunk_end = state.max_chunk_end.max(chunk_end);
202
203        // Guard: hdr_size must be at least 8 (magic + header_size)
204        if hdr_size < 8 {
205            state
206                .issues
207                .push(format!("Chunk at offset {pos} has invalid header_size={hdr_size}"));
208            pos = chunk_end;
209            continue;
210        }
211
212        match magic {
213            MHFD => {
214                // Descend into MHFD children at +12 (past the 12-byte header).
215                let children_start = pos + 12;
216                let children_end = chunk_end.min(end);
217                if children_start < children_end {
218                    integrity_walk_tree(data, children_start, children_end, depth + 1, little_endian, state);
219                }
220            }
221            MHSD => {
222                // Descend into MHSD children at +16 (past the 16-byte header).
223                let children_start = pos + 16;
224                let children_end = chunk_end.min(end);
225                if children_start < children_end {
226                    integrity_walk_tree(data, children_start, children_end, depth + 1, little_endian, state);
227                }
228            }
229            MHL => {
230                // Descend into MHL children at +12 (past the 12-byte header).
231                let children_start = pos + 12;
232                let children_end = chunk_end.min(end);
233                if children_start < children_end {
234                    integrity_walk_tree(data, children_start, children_end, depth + 1, little_endian, state);
235                }
236            }
237            MHII | MHIF | MHOD => {
238                // Leaf node — total_len at +8 already used for chunk_end, or skip.
239            }
240            MHBA | MHIA => {
241                // Descend into album/album-item children at +12.
242                let children_start = pos + 12;
243                let children_end = chunk_end.min(end);
244                if children_start < children_end {
245                    integrity_walk_tree(data, children_start, children_end, depth + 1, little_endian, state);
246                }
247            }
248            MHNI => {
249                // Collect entry data.
250                if hdr_size >= 36 && pos + 28 <= data.len() {
251                    let format_id = read_i32(data, pos + 16, little_endian);
252                    let ithmb_offset = read_i32(data, pos + 20, little_endian);
253                    let image_size = read_i32(data, pos + 24, little_endian);
254                    state.entries.push(MhniEntry {
255                        format_id,
256                        ithmb_offset,
257                        image_size,
258                    });
259                }
260                // Also track the chunk end for boundary tracking.
261            }
262            _ => {
263                // Unknown magic (should never happen after has_child_chunks
264                // check, but handle defensively).
265                state
266                    .issues
267                    .push(format!("Unknown chunk magic 0x{magic:08x} at offset {pos}"));
268            }
269        }
270
271        // Advance to the next chunk. Use chunk_end; if it didn't advance,
272        // force 1-byte to avoid infinite loop.
273        let next_pos = chunk_end.max(pos + 1);
274        if next_pos <= pos {
275            break;
276        }
277        pos = next_pos;
278    }
279}
280
281// ---------------------------------------------------------------------------
282// LE byte-by-byte writers (no std::io::Write, no BinaryWriter, no byteorder
283// crate)
284// ---------------------------------------------------------------------------
285
286fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) {
287    buf[offset] = (value & 0xff) as u8;
288    buf[offset + 1] = ((value >> 8) & 0xff) as u8;
289    buf[offset + 2] = ((value >> 16) & 0xff) as u8;
290    buf[offset + 3] = ((value >> 24) & 0xff) as u8;
291}
292
293fn write_i32_le(buf: &mut [u8], offset: usize, value: i32) {
294    write_u32_le(buf, offset, value as u32);
295}
296
297fn write_u16_le(buf: &mut [u8], offset: usize, value: u16) {
298    buf[offset] = (value & 0xff) as u8;
299    buf[offset + 1] = ((value >> 8) & 0xff) as u8;
300}
301
302// ---------------------------------------------------------------------------
303// Public API
304// ---------------------------------------------------------------------------
305
306/// Validate the structure of a PhotoDB/ArtworkDB binary.
307///
308/// Returns a list of human-readable issue strings. An empty `Vec` means the
309/// binary appears structurally sound.
310///
311/// # Checks performed
312///
313/// 1. Minimum size (at least 4 bytes)
314/// 2. Magic signature (must start with MHFD in either endianness)
315/// 3. Endianness detection
316/// 4. Try full parse (profile DB load) — note failure but continue
317/// 5. Validate MHFD header (size >= 12, within bounds)
318/// 6. Walk chunk tree: collect MHNI entries, track max boundary
319/// 7. Validate known format IDs against profile DB
320/// 8. Check overlapping MHNI ithmb offset ranges
321/// 9. Check trailing garbage after last known chunk boundary
322///
323/// This function has no external I/O. All operations are on the supplied byte
324/// slice.
325#[must_use]
326pub fn integrity_check_photodb(data: &[u8]) -> Vec<String> {
327    let mut issues: Vec<String> = Vec::new();
328
329    // 1. Minimum size check.
330    if data.len() < 4 {
331        issues.push(format!("Data too short: {} bytes (need at least 4)", data.len()));
332        return issues;
333    }
334
335    // 2. Magic check.
336    if !can_open_photodb(data) {
337        issues.push("File does not start with valid MHFD magic".into());
338        return issues;
339    }
340
341    // 3. Endianness detection.
342    let little_endian = match detect_endianness(data) {
343        Some(le) => le,
344        None => {
345            issues.push("Cannot determine endianness from MHFD header".into());
346            return issues;
347        }
348    };
349
350    // 4. Try full parse — try to load profiles, note failure but continue.
351    let profile_db = match ProfileDb::load_builtin() {
352        Ok(db) => Some(db),
353        Err(e) => {
354            issues.push(format!(
355                "Cannot load profile DB (continuing with limited validation): {e}"
356            ));
357            None
358        }
359    };
360
361    // 5. Validate MHFD header.
362    if data.len() < 12 {
363        issues.push(format!(
364            "Data too short for MHFD header: {} bytes (need at least 12)",
365            data.len()
366        ));
367        return issues;
368    }
369    let mhfd_hdr_size = read_u32(data, 4, little_endian) as usize;
370    if mhfd_hdr_size < 12 {
371        issues.push(format!("MHFD header_size ({mhfd_hdr_size}) is less than minimum 12"));
372    }
373    if mhfd_hdr_size > data.len() {
374        issues.push(format!(
375            "MHFD header_size ({mhfd_hdr_size}) exceeds data length ({})",
376            data.len()
377        ));
378    }
379    // entry_count validation (informational)
380    let mhfd_entry_count = if data.len() >= 12 {
381        read_u32(data, 8, little_endian)
382    } else {
383        0
384    };
385
386    // 6. Walk chunk tree.
387    let mut state = WalkState {
388        entries: Vec::new(),
389        max_chunk_end: mhfd_hdr_size, // start from MHFD's claimed extent
390        issues: Vec::new(),
391    };
392
393    // Walk starts at offset 0 (the root MHFD) and covers the entire data
394    // length. Individual sections bound their children via their own
395    // header_size fields.
396    integrity_walk_tree(data, 0, data.len(), 0, little_endian, &mut state);
397
398    // Collect walker-reported issues.
399    issues.append(&mut state.issues);
400
401    // 7. Validate known format IDs against profile DB.
402    if let Some(ref db) = profile_db {
403        for (idx, entry) in state.entries.iter().enumerate() {
404            if db.get(entry.format_id).is_none() {
405                issues.push(format!(
406                    "Entry {idx}: unknown format ID {} (no matching profile)",
407                    entry.format_id
408                ));
409            }
410        }
411    }
412
413    // 8. Check overlapping MHNI ithmb offset ranges.
414    for i in 0..state.entries.len() {
415        for j in (i + 1)..state.entries.len() {
416            let a = &state.entries[i];
417            let b = &state.entries[j];
418            // Two ranges overlap if a starts before b ends and b starts before
419            // a ends.
420            if a.ithmb_offset >= 0 && b.ithmb_offset >= 0 && a.image_size > 0 && b.image_size > 0 {
421                let a_start = a.ithmb_offset as usize;
422                let a_end = a_start + a.image_size as usize;
423                let b_start = b.ithmb_offset as usize;
424                let b_end = b_start + b.image_size as usize;
425
426                if a_start < b_end && b_start < a_end {
427                    issues.push(format!(
428                        "Entries {i} and {j} have overlapping ithmb offset ranges \
429                         (entry {i}: [{a_start}..{a_end}), entry {j}: [{b_start}..{b_end}))"
430                    ));
431                }
432            }
433        }
434    }
435
436    // 9. Check trailing garbage after last known chunk boundary.
437    let effective_end = state.max_chunk_end.max(mhfd_hdr_size);
438    if effective_end < data.len() {
439        let garbage_bytes = data.len() - effective_end;
440        // Allow up to 3 bytes of slop (padding / alignment).
441        if garbage_bytes > 3 {
442            issues.push(format!(
443                "Trailing garbage after last known chunk boundary: \
444                 {garbage_bytes} bytes at offset {effective_end} (file length: {})",
445                data.len()
446            ));
447        }
448    }
449
450    // Also report the number of MHSD sections and entries found, unless
451    // issues already exist.
452    if issues.is_empty() {
453        issues.push(format!(
454            "PhotoDB appears valid: {mhfd_entry_count} section(s), {} entry(s)",
455            state.entries.len()
456        ));
457    }
458
459    issues
460}
461
462/// Build a synthetic PhotoDB/ArtworkDB binary from a list of entries.
463///
464/// The resulting binary has the layout:
465///
466/// ```text
467/// [MHFD 12] [MHSD 16] [MHNI(0) .. MHNI(N-1)] [pixels(0) .. pixels(N-1)]
468/// ```
469///
470/// # Errors
471///
472/// Returns [`DecodeError::InvalidFormat`] if:
473/// - `entries` is empty
474/// - Any entry's `format_id` is not found in the built-in profile DB
475/// - Any entry's `data` length does not match the profile's
476///   `frame_byte_length`
477///
478/// # Panics
479///
480/// Panics if arithmetic overflows (this should never happen with real-world
481/// inputs since the max file size is well under 2³¹ bytes).
482pub fn try_build_photodb(
483    entries: &[BuildEntry],
484    mhni_header_size: i32,
485    mhni_padding_size: i32,
486) -> Result<Vec<u8>, DecodeError> {
487    // 1. Validate inputs.
488    if entries.is_empty() {
489        return Err(DecodeError::InvalidFormat(
490            "Cannot build PhotoDB with zero entries".into(),
491        ));
492    }
493
494    let db = ProfileDb::load_builtin().map_err(|e| DecodeError::Profile(format!("Failed to load profile DB: {e}")))?;
495
496    let mhni_total_len = mhni_header_size + mhni_padding_size;
497    let mhni_total_len_usize = mhni_total_len as usize;
498
499    // Resolve profiles and validate data lengths.
500    let mut profiles: Vec<&Profile> = Vec::with_capacity(entries.len());
501    for (idx, entry) in entries.iter().enumerate() {
502        let profile = db
503            .get(entry.format_id)
504            .ok_or_else(|| DecodeError::InvalidFormat(format!("Entry {idx}: unknown format ID {}", entry.format_id)))?;
505        let expected_len = profile.frame_byte_length as usize;
506        if entry.data.len() != expected_len {
507            return Err(DecodeError::InvalidFormat(format!(
508                "Entry {idx}: format ID {} has data length {}, expected {} (frame_byte_length)",
509                entry.format_id,
510                entry.data.len(),
511                expected_len,
512            )));
513        }
514        profiles.push(profile);
515    }
516
517    // 2. Calculate layout.
518    let n = entries.len();
519    let mhsd_header_size = 16 + (n * mhni_total_len_usize) + entries.iter().map(|e| e.data.len()).sum::<usize>();
520    let total_size = 12 + mhsd_header_size;
521
522    let mut buf = vec![0u8; total_size];
523
524    // 3. Write MHFD header (12 bytes).
525    //    magic = "mhfd", header_size = 12, entry_count = 1
526    buf[0..4].copy_from_slice(b"mhfd");
527    write_u32_le(&mut buf, 4, 12);
528    write_u32_le(&mut buf, 8, 1); // one MHSD section
529
530    // 4. Write MHSD header (16 bytes).
531    //    magic = "mhsd", header_size = 16 + N*mhniTotalLen + totalPixelData,
532    //    index = 0, recordType = 4 (thumbnails), entryCount = N
533    let mhsd_offset = 12;
534    buf[mhsd_offset..mhsd_offset + 4].copy_from_slice(b"mhsd");
535    write_u32_le(&mut buf, mhsd_offset + 4, mhsd_header_size as u32);
536    write_u16_le(&mut buf, mhsd_offset + 8, 0); // index
537    write_u16_le(&mut buf, mhsd_offset + 10, 4); // recordType = 4 (thumbnails)
538    write_u32_le(&mut buf, mhsd_offset + 12, n as u32); // entryCount
539
540    // 5. Write MHNI entries.
541    let mhni_start = mhsd_offset + 16; // past MHSD header
542    let pixel_data_start = mhni_start + n * mhni_total_len_usize;
543
544    // Calculate running pixel data offset for each entry.
545    let mut current_pixel_offset = pixel_data_start;
546
547    for (i, (entry, profile)) in entries.iter().zip(profiles.iter()).enumerate() {
548        let mhni_pos = mhni_start + i * mhni_total_len_usize;
549
550        // magic = "mhni"
551        buf[mhni_pos..mhni_pos + 4].copy_from_slice(b"mhni");
552        // headerSize
553        write_u32_le(&mut buf, mhni_pos + 4, mhni_header_size as u32);
554        // totalLen
555        write_u32_le(&mut buf, mhni_pos + 8, mhni_total_len as u32);
556        // entryIndex = 1
557        write_u32_le(&mut buf, mhni_pos + 12, 1);
558        // formatId
559        write_i32_le(&mut buf, mhni_pos + 16, entry.format_id);
560        // ithmbOffset — points to this entry's pixel data
561        write_i32_le(&mut buf, mhni_pos + 20, current_pixel_offset as i32);
562        // imageSize
563        let image_size = entry.data.len() as i32;
564        write_i32_le(&mut buf, mhni_pos + 24, image_size);
565        // padding u32 = 0
566        write_u32_le(&mut buf, mhni_pos + 28, 0);
567        // height (i16) — from profile
568        let height = profile.height as u16;
569        write_u16_le(&mut buf, mhni_pos + 32, height);
570        // width (i16) — from profile
571        let width = profile.width as u16;
572        write_u16_le(&mut buf, mhni_pos + 34, width);
573        // padding zeros (mhni_padding_size bytes)
574        // buf is already zero-filled, so this is already covered.
575        // But ensure the range is explicitly zeroed for clarity.
576        let pad_start = mhni_pos + 36;
577        let pad_end = mhni_pos + mhni_total_len_usize;
578        for b in &mut buf[pad_start..pad_end] {
579            *b = 0;
580        }
581
582        // Advance pixel offset for the next entry.
583        current_pixel_offset += entry.data.len();
584    }
585
586    // 6. Write pixel data blocks.
587    let mut pixel_write_pos = pixel_data_start;
588    for entry in entries {
589        let data_len = entry.data.len();
590        buf[pixel_write_pos..pixel_write_pos + data_len].copy_from_slice(&entry.data);
591        pixel_write_pos += data_len;
592    }
593
594    Ok(buf)
595}
596
597// ---------------------------------------------------------------------------
598// Tests
599// ---------------------------------------------------------------------------
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    /// Helper: create a simple pixel data buffer of the given length with
606    /// known byte pattern.
607    fn make_pixel_data(len: usize) -> Vec<u8> {
608        (0..len).map(|i| (i & 0xFF) as u8).collect()
609    }
610
611    /// Helper: build and roundtrip-check a minimal PhotoDB.
612    fn build_minimal_photodb(entries: &[BuildEntry], mhni_header_size: i32, mhni_padding_size: i32) -> Vec<u8> {
613        try_build_photodb(entries, mhni_header_size, mhni_padding_size).expect("build should succeed")
614    }
615
616    // -----------------------------------------------------------------------
617    // Builder tests
618    // -----------------------------------------------------------------------
619
620    #[test]
621    fn builder_empty_entries_fails() {
622        let result = try_build_photodb(&[], 36, 40);
623        assert!(result.is_err());
624    }
625
626    #[test]
627    fn builder_unknown_format_id_fails() {
628        let entry = BuildEntry {
629            format_id: 9999,
630            data: vec![0u8; 100],
631        };
632        let result = try_build_photodb(&[entry], 36, 40);
633        assert!(result.is_err());
634    }
635
636    #[test]
637    fn builder_data_length_mismatch_fails() {
638        // Profile 1016 has frame_byte_length = 39200 (140x140 RGB565)
639        let entry = BuildEntry {
640            format_id: 1016,
641            data: vec![0u8; 100], // wrong size
642        };
643        let result = try_build_photodb(&[entry], 36, 40);
644        assert!(result.is_err());
645    }
646
647    #[test]
648    fn builder_single_entry_succeeds() {
649        // Profile 1016: 140x140 RGB565, frame_byte_length = 39200
650        let entry = BuildEntry {
651            format_id: 1016,
652            data: make_pixel_data(39200),
653        };
654        let result = try_build_photodb(&[entry], 36, 40).unwrap();
655
656        // Verify size
657        let expected_size = 12 + 16 + 76 + 39200;
658        assert_eq!(result.len(), expected_size);
659
660        // Verify MHFD magic
661        assert_eq!(&result[0..4], b"mhfd");
662        assert_eq!(read_u32_le(&result, 4), 12); // header_size
663        assert_eq!(read_u32_le(&result, 8), 1); // entry_count
664
665        // Verify MHSD header
666        let mhsd_hdr_size: u32 = read_u32_le(&result, 12 + 4);
667        assert!(mhsd_hdr_size >= 16);
668        assert_eq!(read_u16_le(&result, 12 + 8), 0); // index
669        assert_eq!(read_u16_le(&result, 12 + 10), 4); // recordType
670
671        // Verify MHNI entry
672        assert_eq!(&result[28..32], b"mhni");
673        assert_eq!(read_u32_le(&result, 28 + 4), 36); // headerSize
674        assert_eq!(read_u32_le(&result, 28 + 8), 76); // totalLen
675        assert_eq!(read_u32_le(&result, 28 + 12), 1); // entryIndex
676        assert_eq!(read_i32_le(&result, 28 + 16), 1016); // formatId
677        assert_eq!(read_i32_le(&result, 28 + 20), 28 + 76); // ithmbOffset
678        assert_eq!(read_i32_le(&result, 28 + 24), 39200); // imageSize
679
680        // Verify pixel data
681        let pixel_start = 28 + 76;
682        assert_eq!(&result[pixel_start..pixel_start + 39200], &make_pixel_data(39200));
683    }
684
685    #[test]
686    fn builder_multiple_entries_succeeds() {
687        let entries = vec![
688            BuildEntry {
689                format_id: 1016,
690                data: make_pixel_data(39200),
691            },
692            BuildEntry {
693                format_id: 3004,
694                data: make_pixel_data(6160),
695            },
696        ];
697        let result = try_build_photodb(&entries, 36, 40).unwrap();
698
699        let expected_size = 12 + 16 + 2 * 76 + 39200 + 6160;
700        assert_eq!(result.len(), expected_size);
701
702        // First entry ithmb_offset
703        let pixel_data_start = 12 + 16 + 2 * 76;
704        assert_eq!(read_i32_le(&result, 28 + 20), pixel_data_start as i32);
705        assert_eq!(read_i32_le(&result, 28 + 24), 39200);
706
707        // Second entry ithmb_offset
708        let second_mhni = 28 + 76;
709        assert_eq!(
710            read_i32_le(&result, second_mhni + 20) as usize,
711            pixel_data_start + 39200
712        );
713        assert_eq!(read_i32_le(&result, second_mhni + 24), 6160);
714
715        // Verify pixel data
716        let second_pixel = pixel_data_start + 39200;
717        assert_eq!(
718            &result[pixel_data_start..pixel_data_start + 39200],
719            &make_pixel_data(39200)
720        );
721        assert_eq!(&result[second_pixel..second_pixel + 6160], &make_pixel_data(6160));
722    }
723
724    #[test]
725    fn builder_custom_mhni_sizes() {
726        // Use non-default header/padding sizes.
727        let entry = BuildEntry {
728            format_id: 1016,
729            data: make_pixel_data(39200),
730        };
731        let result = try_build_photodb(&[entry], 40, 50).unwrap();
732
733        let _ = 40 + 50;
734        assert_eq!(read_u32_le(&result, 28 + 4), 40); // headerSize
735        assert_eq!(read_u32_le(&result, 28 + 8), 90); // totalLen
736        // ithmbOffset should use the custom totalLen
737        let expected_off = 12 + 16 + 90; // MHFD + MHSD + one MHNI
738        assert_eq!(read_i32_le(&result, 28 + 20), expected_off);
739    }
740
741    // -----------------------------------------------------------------------
742    // Integrity check tests
743    // -----------------------------------------------------------------------
744
745    #[test]
746    fn integrity_empty_data() {
747        let issues = integrity_check_photodb(b"");
748        assert!(!issues.is_empty());
749        assert!(issues[0].contains("too short"));
750    }
751
752    #[test]
753    fn integrity_too_short() {
754        let issues = integrity_check_photodb(b"mhf");
755        assert!(!issues.is_empty());
756    }
757
758    #[test]
759    fn integrity_bad_magic() {
760        let issues = integrity_check_photodb(b"XXXX");
761        assert!(!issues.is_empty());
762        assert!(issues[0].contains("magic"));
763    }
764
765    #[test]
766    fn integrity_valid_built_file() {
767        let entry = BuildEntry {
768            format_id: 1016,
769            data: make_pixel_data(39200),
770        };
771        let data = build_minimal_photodb(&[entry], 36, 40);
772        let issues = integrity_check_photodb(&data);
773        // Should be clean or have the informational "appears valid" message
774        let has_clean = issues.iter().any(|i| i.contains("appears valid"));
775        assert!(has_clean, "Expected clean result, got: {issues:?}");
776    }
777
778    #[test]
779    fn integrity_valid_two_entries() {
780        let entries = vec![
781            BuildEntry {
782                format_id: 1016,
783                data: make_pixel_data(39200),
784            },
785            BuildEntry {
786                format_id: 3004,
787                data: make_pixel_data(6160),
788            },
789        ];
790        let data = build_minimal_photodb(&entries, 36, 40);
791        let issues = integrity_check_photodb(&data);
792        let has_clean = issues.iter().any(|i| i.contains("appears valid"));
793        assert!(has_clean, "Expected clean result, got: {issues:?}");
794    }
795
796    #[test]
797    fn integrity_unknown_format_id() {
798        // Tamper a valid file's format ID to an unknown value.
799        let entry = BuildEntry {
800            format_id: 1016,
801            data: make_pixel_data(39200),
802        };
803        let mut data = build_minimal_photodb(&[entry], 36, 40);
804        // Overwrite format_id at offset 28+16=44 with an unknown value
805        write_i32_le(&mut data, 44, 9999);
806        let issues = integrity_check_photodb(&data);
807        let has_unknown = issues.iter().any(|i| i.contains("unknown format ID"));
808        assert!(has_unknown, "Expected unknown format ID issue, got: {issues:?}");
809    }
810
811    #[test]
812    fn integrity_trailing_garbage() {
813        let entry = BuildEntry {
814            format_id: 1016,
815            data: make_pixel_data(39200),
816        };
817        let mut data = build_minimal_photodb(&[entry], 36, 40);
818        // Append garbage bytes
819        data.extend_from_slice(b"GARBAGE_AFTER_END");
820        let issues = integrity_check_photodb(&data);
821        let has_garbage = issues.iter().any(|i| i.contains("garbage"));
822        assert!(has_garbage, "Expected trailing garbage issue, got: {issues:?}");
823    }
824
825    #[test]
826    fn integrity_overlapping_offsets() {
827        // Build two entries, then tamper the second offset to overlap with
828        // the first.
829        let entries = vec![
830            BuildEntry {
831                format_id: 1016,
832                data: make_pixel_data(39200),
833            },
834            BuildEntry {
835                format_id: 3004,
836                data: make_pixel_data(6160),
837            },
838        ];
839        let mut data = build_minimal_photodb(&entries, 36, 40);
840        // Second MHNI is at 28+76=104. Its ithmbOffset at +20=124.
841        // Tamper it to point into the first entry's data.
842        let first_entry_off = read_i32_le(&data, 28 + 20);
843        write_i32_le(&mut data, 104 + 20, first_entry_off + 100);
844        let issues = integrity_check_photodb(&data);
845        let has_overlap = issues.iter().any(|i| i.contains("overlapping"));
846        assert!(has_overlap, "Expected overlap issue, got: {issues:?}");
847    }
848
849    // -----------------------------------------------------------------------
850    // Roundtrip: build → integrity-check
851    // -----------------------------------------------------------------------
852
853    #[test]
854    fn build_then_integrity_check_roundtrip() {
855        let entries = vec![
856            BuildEntry {
857                format_id: 1007,
858                data: make_pixel_data(829_440),
859            },
860            BuildEntry {
861                format_id: 3004,
862                data: make_pixel_data(6160),
863            },
864        ];
865        let data = build_minimal_photodb(&entries, 36, 40);
866        let issues = integrity_check_photodb(&data);
867        let has_clean = issues.iter().any(|i| i.contains("appears valid"));
868        assert!(
869            has_clean,
870            "Roundtrip should produce a valid PhotoDB. Issues: {issues:?}"
871        );
872    }
873
874    // -----------------------------------------------------------------------
875    // Helper test: read helpers for the builder output
876    // -----------------------------------------------------------------------
877
878    fn read_u16_le(data: &[u8], offset: usize) -> u16 {
879        u16::from(data[offset]) | (u16::from(data[offset + 1]) << 8)
880    }
881
882    fn read_i32_le(data: &[u8], offset: usize) -> i32 {
883        let v = u32::from(data[offset])
884            | (u32::from(data[offset + 1]) << 8)
885            | (u32::from(data[offset + 2]) << 16)
886            | (u32::from(data[offset + 3]) << 24);
887        v as i32
888    }
889}