Skip to main content

blad_container/
lib.rs

1//! Container parsing and lossless decomposition.
2//!
3//! The central idea: any image file can be described as an ordered list of byte
4//! [`Segment`]s covering it completely and without overlap. Most segments are
5//! [`SegmentKind::Verbatim`] — headers, IFDs, metadata, previews — and are stored as-is
6//! because they are small and not worth modelling. One or more segments are
7//! [`SegmentKind::Image`], holding raw pixel or sensor data, and those are worth handing
8//! to a real image codec.
9//!
10//! Reassembling the segments in `src_offset` order must reproduce the original file
11//! byte for byte. That property is the entire contract, and it is what makes archival
12//! safe: we never need to understand a file completely, only the parts we choose to
13//! recompress.
14
15use std::fs::File;
16use std::io::{Read, Seek, SeekFrom};
17use std::path::Path;
18
19pub mod tiff;
20
21#[derive(Debug, thiserror::Error)]
22pub enum Error {
23    #[error("io: {0}")]
24    Io(#[from] std::io::Error),
25    #[error("not a recognised container")]
26    UnknownFormat,
27    #[error("malformed {container}: {detail}")]
28    Malformed {
29        container: &'static str,
30        detail: String,
31    },
32    #[error("unsupported: {0}")]
33    Unsupported(String),
34}
35
36pub type Result<T> = std::result::Result<T, Error>;
37
38/// How pixel data in an image segment is laid out.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub enum PixelLayout {
41    /// Interleaved samples, e.g. RGBRGB.
42    Chunky,
43    /// Single-channel colour filter array (Bayer mosaic).
44    Cfa,
45}
46
47/// Description of a run of raw pixel data.
48#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49pub struct ImageSpec {
50    pub width: u32,
51    pub height: u32,
52    pub bits_per_sample: u16,
53    pub samples_per_pixel: u16,
54    pub layout: PixelLayout,
55    /// Byte order of the samples *as stored in the file*.
56    pub little_endian: bool,
57}
58
59impl ImageSpec {
60    /// Expected byte length of the pixel data.
61    pub fn byte_len(&self) -> u64 {
62        u64::from(self.width)
63            * u64::from(self.height)
64            * u64::from(self.samples_per_pixel)
65            * u64::from(self.bits_per_sample / 8)
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub enum SegmentKind {
71    /// Stored byte-for-byte. Headers, metadata, previews, anything we do not model.
72    Verbatim,
73    /// Raw pixel data, eligible for recompression by a codec.
74    Image(ImageSpec),
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78pub struct Segment {
79    pub src_offset: u64,
80    pub len: u64,
81    pub kind: SegmentKind,
82}
83
84/// A complete, gapless, non-overlapping description of a file.
85fn orientation_default() -> u16 {
86    1
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
90pub struct Layout {
91    pub container: String,
92    pub total_len: u64,
93    /// TIFF/Exif orientation (tag 274). 1 means "already upright".
94    ///
95    /// Stored pixels are frequently *not* upright: cameras record the sensor readout as
96    /// captured and note the rotation separately. Anything that displays the pixels
97    /// without consulting this — a thumbnailer, say — shows the photo on its side.
98    #[serde(default = "orientation_default")]
99    pub orientation: u16,
100    pub segments: Vec<Segment>,
101}
102
103impl Layout {
104    /// Verify the invariant the whole design rests on: segments must tile the file
105    /// exactly — sorted, contiguous, starting at 0, ending at `total_len`.
106    ///
107    /// Called on every archive and every restore. A layout that fails this can never
108    /// reproduce the original, so we refuse it rather than write an archive we cannot
109    /// honour.
110    pub fn validate(&self) -> Result<()> {
111        let mut cursor = 0u64;
112        for (i, s) in self.segments.iter().enumerate() {
113            if s.src_offset != cursor {
114                return Err(Error::Malformed {
115                    container: "layout",
116                    detail: format!(
117                        "segment {i} starts at {} but previous coverage ended at {cursor}",
118                        s.src_offset
119                    ),
120                });
121            }
122            if let SegmentKind::Image(spec) = &s.kind {
123                if spec.byte_len() != s.len {
124                    return Err(Error::Malformed {
125                        container: "layout",
126                        detail: format!(
127                            "segment {i}: spec implies {} bytes, segment claims {}",
128                            spec.byte_len(),
129                            s.len
130                        ),
131                    });
132                }
133            }
134            cursor = cursor.checked_add(s.len).ok_or_else(|| Error::Malformed {
135                container: "layout",
136                detail: "segment length overflow".into(),
137            })?;
138        }
139        if cursor != self.total_len {
140            return Err(Error::Malformed {
141                container: "layout",
142                detail: format!("segments cover {cursor} bytes, file is {}", self.total_len),
143            });
144        }
145        Ok(())
146    }
147
148    pub fn image_segments(&self) -> impl Iterator<Item = (usize, &Segment, &ImageSpec)> {
149        self.segments
150            .iter()
151            .enumerate()
152            .filter_map(|(i, s)| match &s.kind {
153                SegmentKind::Image(spec) => Some((i, s, spec)),
154                SegmentKind::Verbatim => None,
155            })
156    }
157
158    /// Total bytes held in image segments — the part a codec can act on.
159    pub fn payload_len(&self) -> u64 {
160        self.image_segments().map(|(_, s, _)| s.len).sum()
161    }
162
163    /// Total bytes stored verbatim — the skeleton.
164    pub fn skeleton_len(&self) -> u64 {
165        self.total_len - self.payload_len()
166    }
167}
168
169/// Identify a file and decompose it.
170pub fn analyze(path: &Path) -> Result<Layout> {
171    let mut f = File::open(path)?;
172    let total_len = f.metadata()?.len();
173    let mut magic = [0u8; 4];
174    f.seek(SeekFrom::Start(0))?;
175    f.read_exact(&mut magic)?;
176    f.seek(SeekFrom::Start(0))?;
177
178    match &magic {
179        // TIFF/EP and everything built on it: TIFF, DNG, 3FR, FFF, NEF, ARW, CR2 …
180        [b'I', b'I', 42, 0] | [b'M', b'M', 0, 42] => tiff::analyze(&mut f, total_len),
181        _ => Err(Error::UnknownFormat),
182    }
183}
184
185/// Build a gapless layout from a set of known image regions, filling everything else
186/// with verbatim segments.
187///
188/// Regions must be non-overlapping; they are sorted here, so callers may discover them
189/// in any order (IFD traversal order is not file order).
190pub(crate) fn tile(
191    container: &str,
192    total_len: u64,
193    orientation: u16,
194    mut regions: Vec<(u64, u64, ImageSpec)>,
195) -> Result<Layout> {
196    regions.sort_by_key(|(off, _, _)| *off);
197
198    let mut segments = Vec::with_capacity(regions.len() * 2 + 1);
199    let mut cursor = 0u64;
200    for (off, len, spec) in regions {
201        if off < cursor {
202            return Err(Error::Malformed {
203                container: "layout",
204                detail: format!("image region at {off} overlaps previous coverage ending {cursor}"),
205            });
206        }
207        if off > cursor {
208            segments.push(Segment {
209                src_offset: cursor,
210                len: off - cursor,
211                kind: SegmentKind::Verbatim,
212            });
213        }
214        segments.push(Segment {
215            src_offset: off,
216            len,
217            kind: SegmentKind::Image(spec),
218        });
219        cursor = off + len;
220    }
221    if cursor < total_len {
222        segments.push(Segment {
223            src_offset: cursor,
224            len: total_len - cursor,
225            kind: SegmentKind::Verbatim,
226        });
227    }
228
229    let layout = Layout {
230        container: container.to_string(),
231        total_len,
232        orientation,
233        segments,
234    };
235    layout.validate()?;
236    Ok(layout)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn spec(w: u32, h: u32) -> ImageSpec {
244        ImageSpec {
245            width: w,
246            height: h,
247            bits_per_sample: 16,
248            samples_per_pixel: 1,
249            layout: PixelLayout::Cfa,
250            little_endian: true,
251        }
252    }
253
254    #[test]
255    fn tile_fills_gaps_and_validates() {
256        // 100-byte file with one 40-byte image region at offset 20.
257        let l = tile("test", 100, 1, vec![(20, 40, spec(4, 5))]).unwrap();
258        assert_eq!(l.segments.len(), 3);
259        assert_eq!(l.segments[0].kind, SegmentKind::Verbatim);
260        assert_eq!(l.segments[0].len, 20);
261        assert!(matches!(l.segments[1].kind, SegmentKind::Image(_)));
262        assert_eq!(l.segments[2].src_offset, 60);
263        assert_eq!(l.segments[2].len, 40);
264        assert_eq!(l.payload_len(), 40);
265        assert_eq!(l.skeleton_len(), 60);
266    }
267
268    #[test]
269    fn tile_sorts_out_of_order_regions() {
270        let l = tile(
271            "test",
272            100,
273            1,
274            vec![(60, 20, spec(2, 5)), (10, 20, spec(2, 5))],
275        )
276        .unwrap();
277        l.validate().unwrap();
278        let offsets: Vec<u64> = l.segments.iter().map(|s| s.src_offset).collect();
279        assert_eq!(offsets, vec![0, 10, 30, 60, 80]);
280    }
281
282    #[test]
283    fn image_region_at_file_start_and_end_needs_no_padding() {
284        let l = tile("test", 40, 1, vec![(0, 40, spec(4, 5))]).unwrap();
285        assert_eq!(l.segments.len(), 1);
286        assert_eq!(l.skeleton_len(), 0);
287    }
288
289    #[test]
290    fn overlapping_regions_are_rejected() {
291        let e = tile(
292            "test",
293            100,
294            1,
295            vec![(10, 30, spec(2, 5)), (20, 20, spec(2, 5))],
296        );
297        assert!(e.is_err());
298    }
299
300    #[test]
301    fn spec_mismatch_is_rejected() {
302        // Claim 40 bytes but the spec describes 4*5*1*2 = 40 … make it disagree.
303        let bad = tile("test", 100, 1, vec![(0, 39, spec(4, 5))]);
304        assert!(bad.is_err());
305    }
306
307    #[test]
308    fn validate_rejects_short_coverage() {
309        let l = Layout {
310            container: "test".into(),
311            total_len: 100,
312            orientation: 1,
313            segments: vec![Segment {
314                src_offset: 0,
315                len: 50,
316                kind: SegmentKind::Verbatim,
317            }],
318        };
319        assert!(l.validate().is_err());
320    }
321}