Skip to main content

lumen/
validate.rs

1//! The checks of [`spec/17-validation.md`] section 11.
2//!
3//! Validation returns the **first** failure, in the order the specification
4//! presents the checks: file framing, then presence, then each chunk's semantic
5//! rules, then layer data, then the integrity tree, then encryption.
6//!
7//! That ordering is load-bearing. The conformance corpus asserts that each
8//! invalid vector fails its advertised check *first*, so a check that runs
9//! earlier than the specification's order can mask the one a vector pins. Three
10//! orderings are chosen against the tempting alternative:
11//!
12//! - `presence.auth` runs before `crypt.chunk_flags`. A file whose header sets
13//!   `ENCRYPTED` with no `AUTH` chunk fails both, and the corpus pins the
14//!   presence check for that vector.
15//! - Every check over a chunk payload runs *after* `crypt.chunk_flags`. A
16//!   descriptor that claims a payload is sealed while the file has no key would
17//!   otherwise be read as plaintext and reported as a JSON error rather than a
18//!   flag inconsistency.
19//! - The integer rule for durations runs over the raw JSON, before the typed
20//!   parse of the chunk carrying it. A fractional duration is a defective value,
21//!   not a defective document, so it reports the named check (`meta.time_integer`
22//!   and its siblings) rather than the generic JSON error a failed
23//!   deserialization would produce.
24//!
25//! A sealed file validated without a key is checked as far as its plaintext
26//! allows. The directory, `HEAD`, `AUTH`, `LTBL`, the `LAYR` version fields and
27//! `LHAS` are plaintext by construction (section 9.1), so those checks all run;
28//! the ones that need decrypted content are skipped rather than guessed at.
29//! Supply the key with [`Validator::with_key`] to run them.
30
31use crate::check::Check;
32use crate::chunkio;
33use crate::chunks::extd::{Extension, CRITICAL_BIT, EXTD_RESERVED_MASK};
34use crate::chunks::head::Head;
35use crate::chunks::layr;
36use crate::chunks::lhas::{self, LayerHashes};
37use crate::chunks::ltbl::{LayerEntry, LayerTable};
38use crate::chunks::preview::{self, PreviewRole};
39use crate::chunks::voxl;
40use crate::chunks::zdic::ZstdDictionary;
41use crate::chunks::{self};
42use crate::container::{
43    self, ChunkDescriptor, ChunkType as Tag, Directory, FileHeader, CHUNK_FLAG_ENCRYPTED,
44    FILE_HEADER_SIZE,
45};
46use crate::crypto::{self, Cipher, SessionKey};
47use crate::error::{Error, Result};
48use crate::json::{Material, Meta, Profile, Timing, REQUIRED_META_FIELDS};
49use crate::ree;
50use serde_json::{Map, Value};
51use std::borrow::Cow;
52use std::cell::{Cell, RefCell};
53
54/// Header flag bits 0, 2 and 4: reserved, and required to be zero.
55///
56/// Bits 5-31 are reserved for future use and a reader must ignore them
57/// (section 3.1), so they are outside this mask.
58const HEADER_MUST_BE_ZERO: u32 = (1 << 0) | (1 << 2) | (1 << 4);
59
60/// The highest duty `light_pwm` and `bottom_light_pwm` may carry (section 4.2):
61/// the value is a fraction of full power over `0..=255`, not an arbitrary count.
62const PWM_MAX: u32 = 255;
63
64/// The durations the timing namespace defines, in whole milliseconds: META
65/// (section 4.2, its `sectors` entries included), a `PROF`'s `settings` block
66/// (4.3) and an `LROV` payload (4.5) all draw on these keys.
67///
68/// They are listed rather than taken from [`Timing`] because the rule is checked
69/// against the raw JSON, before the typed parse: a `*_ms` key the namespace does
70/// not define is an unknown field, which is preserved rather than measured.
71const TIME_MS_FIELDS: [&str; 8] = [
72    "normal_exposure_ms",
73    "bottom_exposure_ms",
74    "wait_time_before_cure_ms",
75    "wait_time_after_cure_ms",
76    "wait_time_after_lift_ms",
77    "bottom_wait_time_before_cure_ms",
78    "bottom_wait_time_after_cure_ms",
79    "bottom_wait_time_after_lift_ms",
80];
81
82/// META's own duration, informational and outside the namespace a sector
83/// entry, a `PROF.settings` block or an `LROV` payload draws on (section 4.2).
84/// It is the
85/// one duration in whole seconds rather than milliseconds: an estimate that
86/// spans hours has no millisecond precision to report.
87const ESTIMATED_PRINT_TIME_SEC: &str = "estimated_print_time_sec";
88
89/// The chunk types that carry content and must therefore be sealed whenever the
90/// file is (section 9.1).
91const CONTENT_CHUNKS: &[Tag] = &[
92    Tag::META,
93    Tag::PROF,
94    Tag::LROV,
95    Tag::VOXL,
96    Tag::ZDIC,
97    Tag::LAYR,
98];
99
100/// The chunk types that must never be sealed, because a reader needs them
101/// before it has a key.
102const CLEARTEXT_CHUNKS: &[Tag] = &[Tag::HEAD, Tag::AUTH, Tag::LTBL, Tag::LHAS];
103
104/// Record `[start, end)` of one sector's slice as exposed.
105///
106/// `covered` holds what the layer's earlier sectors exposed and `mine` what this
107/// one has, both as one bit per pixel. A pixel in `covered` that is not in `mine`
108/// was exposed by another sector of the same layer, which section 7.3 forbids.
109/// The ranges are marked a word at a time: a whole 64-pixel word of a detailed
110/// 16K layer costs one test rather than sixty-four.
111fn expose(
112    covered: &mut [u64],
113    mine: &mut [u64],
114    start: u64,
115    end: u64,
116    layer: u32,
117    sector: u32,
118) -> Result<()> {
119    if start >= end {
120        return Ok(());
121    }
122    let first = start as usize / 64;
123    let last = (end - 1) as usize / 64;
124    for word in first..=last {
125        let low = if word == first {
126            start as usize % 64
127        } else {
128            0
129        };
130        let high = if word == last {
131            (end - 1) as usize % 64
132        } else {
133            63
134        };
135        let bits = (u64::MAX << low) & (u64::MAX >> (63 - high));
136        if covered[word] & bits & !mine[word] != 0 {
137            return Err(Error::new(
138                Check::SectorPartition,
139                format!(
140                    "layer {layer}: sector {sector} exposes a pixel another sector already exposes"
141                ),
142            ));
143        }
144        covered[word] |= bits;
145        mine[word] |= bits;
146    }
147    Ok(())
148}
149
150/// How thoroughly to validate.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
152pub enum Level {
153    /// Default for printing: accept structurally valid files, skip what is
154    /// unknown, and do not enforce canonical encodings.
155    #[default]
156    Loose,
157    /// For verification tools: enforce every rule, including the strict-mode
158    /// ones marked in section 11.
159    Strict,
160}
161
162impl Level {
163    /// Whether this level enforces the strict-mode checks.
164    pub fn is_strict(self) -> bool {
165        matches!(self, Level::Strict)
166    }
167}
168
169/// A configured validator.
170#[derive(Debug, Clone, Default)]
171pub struct Validator {
172    level: Level,
173    key: Option<SessionKey>,
174}
175
176impl Validator {
177    /// A validator at `level`, with no key.
178    pub fn new(level: Level) -> Validator {
179        Validator { level, key: None }
180    }
181
182    /// A loose validator.
183    pub fn loose() -> Validator {
184        Validator::new(Level::Loose)
185    }
186
187    /// A strict validator.
188    pub fn strict() -> Validator {
189        Validator::new(Level::Strict)
190    }
191
192    /// Supply the session key, which enables the checks that need to open
193    /// sealed units: `crypt.tag_verify`, and every check over decrypted content.
194    pub fn with_key(mut self, key: SessionKey) -> Validator {
195        self.key = Some(key);
196        self
197    }
198
199    /// The configured level.
200    pub fn level(&self) -> Level {
201        self.level
202    }
203
204    /// The configured key, if any.
205    pub fn key(&self) -> Option<&SessionKey> {
206        self.key.as_ref()
207    }
208
209    /// Validate, returning the first failure.
210    pub fn validate(&self, buf: &[u8]) -> Result<()> {
211        let mut ctx = Ctx::new(buf, self.level, self.key)?;
212        ctx.check_framing()?;
213        ctx.check_chunk_flags()?;
214
215        let head = ctx.read_hdr()?;
216        check_hdr(&head)?;
217        ctx.display_pixels.set(head.total_pixels());
218
219        let auth = ctx.read_auth()?;
220        ctx.cipher = auth.as_ref().map(|a| a.cipher);
221
222        let meta = ctx.read_meta()?;
223        if let Some(meta) = meta.as_ref() {
224            check_meta(meta)?;
225        }
226        let profile = ctx.read_profile()?;
227        if let Some(profile) = profile.as_ref() {
228            check_profile(profile)?;
229        }
230
231        ctx.check_lrov_payloads()?;
232
233        ctx.check_previews()?;
234        ctx.check_extensions()?;
235        ctx.check_voxl()?;
236
237        let ltbl = ctx.read_ltbl()?;
238        let zdic = ctx.read_zdic()?;
239        ctx.check_layer_data(&head, &ltbl, &zdic)?;
240        let hashes = ctx.check_hashes(&head)?;
241        if let Some(hashes) = hashes.as_ref() {
242            ctx.check_leaves(&ltbl, &zdic, hashes)?;
243        }
244
245        if let (Some(key), Some(cipher)) = (ctx.key.as_ref(), ctx.cipher) {
246            ctx.check_sealed_units(key, cipher)?;
247        }
248        Ok(())
249    }
250}
251
252/// Validate `buf` at `level`, returning the first failure.
253pub fn validate(buf: &[u8], level: Level) -> Result<()> {
254    Validator::new(level).validate(buf)
255}
256
257/// Validate an encrypted file, opening sealed units with `key`.
258pub fn validate_with_key(buf: &[u8], level: Level, key: SessionKey) -> Result<()> {
259    Validator::new(level).with_key(key).validate(buf)
260}
261
262/// A validator's parse of the container, shared by every check.
263struct Ctx<'a> {
264    buf: &'a [u8],
265    header: FileHeader,
266    dir: Directory,
267    level: Level,
268    key: Option<SessionKey>,
269    cipher: Option<Cipher>,
270    /// One decompressed `LAYR` chunk, so a walk that revisits a chunk - a
271    /// multi-sector layer's second sector, or a strict second pass - does not
272    /// decompress it twice.
273    cache: RefCell<Option<(u32, Vec<u8>)>>,
274    /// The display's pixel count, set once `HEAD` is parsed. A frame's declared
275    /// output is bounded per slice of it (section 11.3), and the chunk walks run
276    /// after the header, so by then it is known.
277    display_pixels: Cell<u32>,
278}
279
280impl<'a> Ctx<'a> {
281    fn new(buf: &'a [u8], level: Level, key: Option<SessionKey>) -> Result<Ctx<'a>> {
282        container::verify_trailer_crc(buf)?;
283        let header = FileHeader::parse(buf)?;
284        let dir = container::parse_directory(buf, &header)?;
285        Ok(Ctx {
286            buf,
287            header,
288            dir,
289            level,
290            key,
291            cipher: None,
292            cache: RefCell::new(None),
293            display_pixels: Cell::new(0),
294        })
295    }
296
297    /// The stored bytes of a chunk, sealed or not.
298    fn stored(&self, d: &ChunkDescriptor) -> Result<&'a [u8]> {
299        chunkio::stored(self.buf, d)
300    }
301
302    /// The plaintext bytes of a chunk, or `None` when it is sealed and no key is
303    /// available.
304    fn plaintext(&self, d: &ChunkDescriptor) -> Result<Option<Vec<u8>>> {
305        let stored = self.stored(d)?;
306        chunkio::plaintext(d, stored, self.cipher, self.key.as_ref())
307    }
308
309    /// The decrypted, decompressed payload of a chunk, or `None` when it cannot
310    /// be read.
311    fn payload(&self, d: &ChunkDescriptor) -> Result<Option<Vec<u8>>> {
312        let stored = self.stored(d)?;
313        chunkio::payload(d, stored, self.cipher, self.key.as_ref())
314    }
315
316    // -- phase 1: framing ---------------------------------------------------
317
318    fn check_framing(&mut self) -> Result<()> {
319        if self.header.flags & HEADER_MUST_BE_ZERO != 0 {
320            return Err(Error::new(
321                Check::HeaderFlagsReserved,
322                format!(
323                    "header.flags {:#010x} sets a reserved bit",
324                    self.header.flags
325                ),
326            ));
327        }
328
329        let dir_start = self.header.dir_offset;
330        let mut ranges: Vec<(u64, u64, Tag)> = Vec::new();
331        for d in self.dir.entries() {
332            let (start, end) = d.extent();
333            if start < FILE_HEADER_SIZE as u64 || end > dir_start {
334                return Err(Error::new(
335                    Check::DirChunkExtent,
336                    format!(
337                        "chunk {} occupies {start}..{end}, outside the {}..{dir_start} payload region",
338                        d.chunk_type, FILE_HEADER_SIZE
339                    ),
340                ));
341            }
342            ranges.push((start, end, d.chunk_type));
343        }
344        ranges.sort_by_key(|r| r.0);
345        for pair in ranges.windows(2) {
346            if pair[1].0 < pair[0].1 {
347                return Err(Error::new(
348                    Check::DirOverlap,
349                    format!(
350                        "chunk {} at {}..{} overlaps chunk {} at {}..{}",
351                        pair[0].2, pair[0].0, pair[0].1, pair[1].2, pair[1].0, pair[1].1
352                    ),
353                ));
354            }
355        }
356
357        if self.header.total_uncompressed_size != 0 {
358            let sum = self.dir.total_uncompressed_size();
359            if sum != self.header.total_uncompressed_size {
360                return Err(Error::new(
361                    Check::HeaderTotalUncompressedSize,
362                    format!(
363                        "header declares {} uncompressed bytes, the directory sums to {sum}",
364                        self.header.total_uncompressed_size
365                    ),
366                ));
367            }
368        }
369
370        if self.dir.find(Tag::HEAD).is_none() {
371            return Err(Error::new(Check::PresenceHead, "no HEAD chunk"));
372        }
373        match self.dir.first_entry_index() {
374            Some(i) if self.dir.descriptors[i].chunk_type == Tag::HEAD => {}
375            _ => {
376                return Err(Error::new(
377                    Check::DirHeadFirst,
378                    "the first chunk is not HEAD",
379                ))
380            }
381        }
382        if self.dir.find(Tag::META).is_none() {
383            return Err(Error::new(Check::PresenceMeta, "no META chunk"));
384        }
385        if self.dir.find(Tag::LTBL).is_none() {
386            return Err(Error::new(Check::PresenceLtbl, "no LTBL chunk"));
387        }
388        // Exactly one: two layer tables can disagree about which chunk holds a
389        // layer's data and where its slice starts, and nothing in the file
390        // resolves that (section 11.1).
391        let tables = self.dir.find_all(Tag::LTBL).count();
392        if tables > 1 {
393            return Err(Error::new(
394                Check::PresenceLtbl,
395                format!("{tables} LTBL chunks; a file carries exactly one layer table"),
396            ));
397        }
398        if self.dir.find(Tag::LAYR).is_none() {
399            return Err(Error::new(Check::PresenceLayr, "no LAYR chunk"));
400        }
401        if self.header.encrypted() && self.dir.find(Tag::AUTH).is_none() {
402            return Err(Error::new(
403                Check::PresenceAuth,
404                "the header sets ENCRYPTED but the file carries no AUTH chunk",
405            ));
406        }
407        Ok(())
408    }
409
410    /// Section 11.4: the sealed bit on each descriptor agrees with the header.
411    fn check_chunk_flags(&self) -> Result<()> {
412        let encrypted_file = self.header.encrypted();
413        for d in self.dir.entries() {
414            let sealed = d.flags & CHUNK_FLAG_ENCRYPTED != 0;
415            if !encrypted_file {
416                if sealed {
417                    return Err(Error::new(
418                        Check::CryptChunkFlags,
419                        format!(
420                            "{} sets the sealed bit, but the file is not encrypted and no key could open it",
421                            d.chunk_type
422                        ),
423                    ));
424                }
425                continue;
426            }
427            if CLEARTEXT_CHUNKS.contains(&d.chunk_type) {
428                if sealed {
429                    return Err(Error::new(
430                        Check::CryptChunkFlags,
431                        format!("{} must stay plaintext", d.chunk_type),
432                    ));
433                }
434            } else if CONTENT_CHUNKS.contains(&d.chunk_type) && !sealed {
435                return Err(Error::new(
436                    Check::CryptChunkFlags,
437                    format!(
438                        "{} is content and must be sealed in an encrypted file",
439                        d.chunk_type
440                    ),
441                ));
442            }
443        }
444        Ok(())
445    }
446
447    // -- phase 2: the plaintext chunks -------------------------------------
448
449    fn read_hdr(&self) -> Result<Head> {
450        let d = self.dir.find(Tag::HEAD).expect("presence checked");
451        let payload = self
452            .plaintext(d)?
453            .expect("HEAD is never sealed, so it is always readable");
454        Head::parse(&payload)
455    }
456
457    fn read_auth(&self) -> Result<Option<crypto::Auth>> {
458        let Some(d) = self.dir.find(Tag::AUTH) else {
459            return Ok(None);
460        };
461        let payload = self
462            .plaintext(d)?
463            .expect("AUTH is never sealed, so it is always readable");
464        let auth = crypto::parse_auth(&payload)?;
465        if let Some(password) = auth.password.as_ref() {
466            // The budget is checked here, before any caller can derive a key
467            // from these parameters.
468            crypto::check_argon2_budget(password)?;
469        }
470        Ok(Some(auth))
471    }
472
473    /// Every `LAYR` chunk, in directory order: its index and its descriptor.
474    fn layr_chunks(&self) -> Vec<(u32, ChunkDescriptor)> {
475        self.dir
476            .descriptors
477            .iter()
478            .enumerate()
479            .filter(|(_, d)| !d.is_null() && d.chunk_type == Tag::LAYR)
480            .map(|(index, d)| (index as u32, *d))
481            .collect()
482    }
483
484    /// The plaintext frame of a `LAYR` chunk, or `None` when it is sealed and no
485    /// key is available.
486    ///
487    /// `index` is the chunk's directory index, which is what its frame's
488    /// associated data binds it to.
489    fn layr_frame<'b>(&'b self, index: u32, d: &ChunkDescriptor) -> Result<Option<Cow<'b, [u8]>>> {
490        chunkio::layr_frame(
491            d,
492            index,
493            chunkio::stored(self.buf, d)?,
494            self.cipher,
495            self.key.as_ref(),
496        )
497    }
498
499    /// Run `f` over the decompressed output of a `LAYR` chunk, decompressing it
500    /// first if it is not the cached one.
501    fn with_chunk<T>(
502        &self,
503        index: u32,
504        dictionary: Option<&[u8]>,
505        f: impl FnOnce(&[u8]) -> Result<T>,
506    ) -> Result<T> {
507        if self.cache.borrow().as_ref().map(|(k, _)| *k) != Some(index) {
508            let data = {
509                let descriptor = self.layr_descriptor(index)?;
510                let frame = self.layr_frame(index, descriptor)?.ok_or_else(|| {
511                    Error::new(
512                        Check::CryptNoKey,
513                        "the frame is sealed and no session key is available",
514                    )
515                })?;
516                // The frame is released before the output is cached, so the two
517                // are never live at the same time. Its declared output is bounded
518                // by the slices the layer table points into this chunk, which is
519                // the specification's rule (section 11.3) - a ratio guard cannot
520                // stand in for it, because a block of repeating layers compresses
521                // past any fixed ratio and is still conforming.
522                let bound = self.chunk_bound(index, frame.len());
523                chunks::decompress_frame(&frame, dictionary, bound)?
524            };
525            *self.cache.borrow_mut() = Some((index, data));
526        }
527        let guard = self.cache.borrow();
528        let (_, data) = guard.as_ref().expect("filled immediately above");
529        f(data)
530    }
531
532    /// How many slices the layer table points into a `LAYR` chunk.
533    ///
534    /// A slice is an entry that carries bytes, and the count is floored at one so
535    /// that a chunk nothing points into is still allowed one layer's worth: this
536    /// is the bound of section 11.3, and the reader uses the same definition so a
537    /// file cannot pass one and fail the other.
538    fn chunk_bound(&self, index: u32, frame_len: usize) -> u64 {
539        let pixels = self.display_pixels.get();
540        if pixels == 0 {
541            // Unreachable through `validate`, which reads the header first; a
542            // bound from the frame's own bytes is the fallback that cannot be
543            // tighter than a conforming file.
544            return (frame_len as u64).saturating_mul(chunks::MAX_ZSTD_RATIO as u64);
545        }
546        chunks::allocation_bound(self.slices_into(index), pixels)
547    }
548
549    fn slices_into(&self, index: u32) -> u64 {
550        let Ok(Some(table)) = self.read_ltbl() else {
551            return 1;
552        };
553        let count = table
554            .entries
555            .iter()
556            .filter(|entry| !entry.is_empty() && entry.first_layr == index)
557            .count() as u64;
558        count.max(1)
559    }
560
561    /// The descriptor of the `LAYR` chunk at directory index `index`.
562    fn layr_descriptor(&self, index: u32) -> Result<&ChunkDescriptor> {
563        self.dir
564            .descriptors
565            .get(index as usize)
566            .filter(|d| !d.is_null() && d.chunk_type == Tag::LAYR)
567            .ok_or_else(|| {
568                Error::new(
569                    Check::LtblFirstLayrInRange,
570                    format!("directory index {index} is not a LAYR chunk"),
571                )
572            })
573    }
574
575    /// One entry's slice of its `LAYR` chunk's decompressed output.
576    fn slice_of(&self, entry: &LayerEntry, dictionary: Option<&[u8]>) -> Result<Vec<u8>> {
577        if entry.is_empty() {
578            return Ok(Vec::new());
579        }
580        let start = entry.data_offset as usize;
581        let len = entry.data_size as usize;
582        self.with_chunk(entry.first_layr, dictionary, |chunk| {
583            chunk
584                .get(start..start.saturating_add(len))
585                .map(|slice| slice.to_vec())
586                .ok_or_else(|| {
587                    Error::new(
588                        Check::LtblOffsetWithinChunk,
589                        format!(
590                            "a slice of {len} bytes at offset {start} lies outside its chunk's {} bytes",
591                            chunk.len()
592                        ),
593                    )
594                })
595        })
596    }
597
598    /// The stored bytes of a layer: its sectors' slices, concatenated in
599    /// ascending `sector_id`.
600    fn layer_bytes(&self, entries: &[LayerEntry], dictionary: Option<&[u8]>) -> Result<Vec<u8>> {
601        let mut out = Vec::new();
602        for entry in entries {
603            out.extend_from_slice(&self.slice_of(entry, dictionary)?);
604        }
605        Ok(out)
606    }
607
608    fn read_ltbl(&self) -> Result<Option<LayerTable>> {
609        match self.dir.find(Tag::LTBL) {
610            None => Ok(None),
611            Some(d) => {
612                let payload = self
613                    .plaintext(d)?
614                    .expect("LTBL is never sealed, so it is always readable");
615                Ok(Some(LayerTable::parse(&payload)?))
616            }
617        }
618    }
619
620    fn read_zdic(&self) -> Result<Option<ZstdDictionary>> {
621        let mut found = self.dir.find_all(Tag::ZDIC);
622        let Some(d) = found.next() else {
623            return Ok(None);
624        };
625        if found.next().is_some() {
626            return Err(Error::new(
627                Check::ZdicSingle,
628                "more than one ZDIC chunk is present",
629            ));
630        }
631        match self.plaintext(d)? {
632            None => Ok(None),
633            Some(raw) => Ok(Some(ZstdDictionary::parse(&raw)?)),
634        }
635    }
636
637    // -- phase 3: the JSON chunks ------------------------------------------
638
639    /// Parse a `META` payload while keeping its raw object, which is the only
640    /// way to tell an absent required field from one that is `null`.
641    fn read_meta(&self) -> Result<Option<Meta>> {
642        let Some(d) = self.dir.find(Tag::META) else {
643            return Ok(None);
644        };
645        let Some(bytes) = self.payload(d)? else {
646            return Ok(None);
647        };
648        let value: Value = serde_json::from_slice(&bytes).map_err(json_error)?;
649        let object = value
650            .as_object()
651            .ok_or_else(|| Error::new(Check::MetaJson, "META is not a JSON object"))?;
652        let missing = Timing::missing_from(object, &REQUIRED_META_FIELDS);
653        if !missing.is_empty() {
654            return Err(Error::new(
655                Check::MetaRequiredFields,
656                format!("META is missing {}", missing.join(", ")),
657            ));
658        }
659        check_integer_durations(
660            object,
661            TIME_MS_FIELDS
662                .iter()
663                .copied()
664                .chain(std::iter::once(ESTIMATED_PRINT_TIME_SEC)),
665            Check::MetaTimeInteger,
666        )?;
667        check_sectors_shape(object)?;
668        let meta: Meta = serde_json::from_value(value).map_err(json_error)?;
669        Ok(Some(meta))
670    }
671
672    fn read_profile(&self) -> Result<Option<Profile>> {
673        let Some(d) = self.dir.find(Tag::PROF) else {
674            return Ok(None);
675        };
676        let Some(bytes) = self.payload(d)? else {
677            return Ok(None);
678        };
679        let value: Value = serde_json::from_slice(&bytes).map_err(json_error)?;
680        // A profile's durations live in its `settings` block, which draws on
681        // META's own field names. Anything else about the block's shape is the
682        // typed parse's business.
683        if let Some(settings) = value.get("settings").and_then(Value::as_object) {
684            check_integer_durations(
685                settings,
686                TIME_MS_FIELDS.iter().copied(),
687                Check::ProfSettingsTimeInteger,
688            )?;
689        }
690        Ok(Some(serde_json::from_value(value).map_err(json_error)?))
691    }
692
693    /// Every `LROV` chunk's payload rules: the integer rule for the durations a
694    /// delta carries, and nothing else - a delta has no layer, range or sector
695    /// field to check, because the `LTBL` entry that points at it is what places
696    /// it (`lrov.orphan` checks that placement, and needs the table).
697    fn check_lrov_payloads(&self) -> Result<()> {
698        for d in self.dir.find_all(Tag::LROV).collect::<Vec<_>>() {
699            let Some(bytes) = self.payload(d)? else {
700                continue;
701            };
702            let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
703                Error::new(
704                    Check::LrovJson,
705                    format!("the LROV payload is not a valid object: {e}"),
706                )
707            })?;
708            let object = value
709                .as_object()
710                .ok_or_else(|| Error::new(Check::LrovJson, "LROV is not a JSON object"))?;
711            check_integer_durations(
712                object,
713                TIME_MS_FIELDS.iter().copied(),
714                Check::LrovTimeInteger,
715            )?;
716            check_pwm_object(object, "LROV")?;
717        }
718        Ok(())
719    }
720
721    fn check_previews(&self) -> Result<()> {
722        for d in self.dir.find_all(Tag::PREV).collect::<Vec<_>>() {
723            PreviewRole::from_flags(d.flags)?;
724            if self.level.is_strict() {
725                if let Some(bytes) = self.payload(d)? {
726                    preview::check_png(&bytes)?;
727                }
728            }
729        }
730        Ok(())
731    }
732
733    /// `EXTD` frame checks.
734    ///
735    /// A sealed extension is checked only for its flag rules: the specification
736    /// leaves its compression to the extension, and no extension is implemented,
737    /// so its payload is opaque - which is how a reader treats any extension it
738    /// does not implement. The `critical` rule binds either way.
739    fn check_extensions(&self) -> Result<()> {
740        for d in self.dir.find_all(Tag::EXTD).collect::<Vec<_>>() {
741            if d.is_encrypted() {
742                check_opaque_extension(d.flags)?;
743                continue;
744            }
745            let Some(frame) = self.payload(d)? else {
746                continue;
747            };
748            let ext = Extension::parse(&frame, d.flags)?;
749            check_extension(&ext)?;
750        }
751        Ok(())
752    }
753
754    fn check_voxl(&self) -> Result<()> {
755        if !self.level.is_strict() {
756            return Ok(());
757        }
758        for d in self.dir.find_all(Tag::VOXL).collect::<Vec<_>>() {
759            if let Some(bytes) = self.payload(d)? {
760                voxl::require_voxl(&bytes)?;
761            }
762        }
763        Ok(())
764    }
765
766    // -- phase 4: layer data -----------------------------------------------
767
768    /// The `LAYR` chunks, `LTBL`, and every (layer, sector)'s REE stream.
769    ///
770    /// The chunk walk is what keeps peak memory at one chunk: a frame is
771    /// decompressed, its slices are checked, and it is released before the next
772    /// one is read. The `LTBL` rules that need only the table and the frames'
773    /// declared output lengths run first, so a table that lies about where a
774    /// layer sits is reported before anything is decompressed.
775    fn check_layer_data(
776        &self,
777        head: &Head,
778        ltbl: &Option<LayerTable>,
779        zdic: &Option<ZstdDictionary>,
780    ) -> Result<()> {
781        let Some(ltbl) = ltbl.as_ref() else {
782            return Ok(());
783        };
784        let total_layers = head.total_layers;
785        let total_pixels = head.total_pixels();
786        let dict_bytes = zdic.as_ref().map(|z| z.dict_bytes.as_slice());
787
788        if ltbl.layer_count != total_layers {
789            return Err(Error::new(
790                Check::LtblLayerCount,
791                format!(
792                    "LTBL describes {} layers, HEAD says {total_layers}",
793                    ltbl.layer_count
794                ),
795            ));
796        }
797
798        // Section 4.9: MULTI_SECTOR is set exactly when some layer carries more
799        // than one sector with data, so the flag and the table must agree either
800        // way round.
801        if self.header.multi_sector() != ltbl.is_multi_sector() {
802            return Err(Error::new(
803                Check::HeadMultiSectorFlag,
804                if self.header.multi_sector() {
805                    "MULTI_SECTOR is set but no layer carries more than one sector".to_string()
806                } else {
807                    "a layer carries more than one sector but MULTI_SECTOR is clear".to_string()
808                },
809            ));
810        }
811
812        // How many slices each chunk holds, which is what bounds its frame's
813        // declared output.
814        let mut slices_per_chunk: std::collections::HashMap<u32, u64> =
815            std::collections::HashMap::new();
816        for entry in &ltbl.entries {
817            if !entry.is_empty() {
818                *slices_per_chunk.entry(entry.first_layr).or_insert(0) += 1;
819            }
820        }
821
822        // Each chunk's declared output length, by directory index: what the
823        // offset rule measures against. `None` marks a chunk whose frame could
824        // not be opened, which happens only when it is sealed and no key was
825        // supplied.
826        let chunks = self.layr_chunks();
827        let mut output_size: Vec<Option<u64>> = vec![None; self.dir.descriptors.len()];
828        let mut dictionary_used = false;
829        let mut unreadable = false;
830        for (index, descriptor) in &chunks {
831            let stored = self.stored(descriptor)?;
832            // A sealed frame is at least one AEAD unit (section 11.4).
833            if descriptor.flags & CHUNK_FLAG_ENCRYPTED != 0
834                && stored.len() < layr::LAYR_HEADER_LEN + crypto::UNIT_OVERHEAD
835            {
836                return Err(Error::new(
837                    Check::CryptChunkFlags,
838                    format!(
839                        "the frame at directory index {index} is {} bytes, smaller than one \
840                         sealed unit",
841                        stored.len()
842                    ),
843                ));
844            }
845            let Some(frame) = self.layr_frame(*index, descriptor)? else {
846                // Sealed with no key: the version field is plaintext and already
847                // checked by `layr_frame`, and the frame is not ours to read.
848                unreadable = true;
849                continue;
850            };
851            let size = chunks::frame_content_size(&frame)?;
852            output_size[*index as usize] = Some(size);
853            let slices = slices_per_chunk.get(index).copied().unwrap_or(0).max(1);
854            let bound = chunks::allocation_bound(slices, total_pixels);
855            if size > bound {
856                return Err(Error::new(
857                    Check::FrameAllocationBound,
858                    format!(
859                        "the frame at directory index {index} declares {size} bytes; {slices} \
860                         slices of {total_pixels} pixels bound it at {bound}"
861                    ),
862                ));
863            }
864            match (chunks::frame_dict_id(&frame)?, zdic.as_ref()) {
865                // A frame that reports no dictionary is not this rule's business
866                // either way: an encoder may compress one frame without the
867                // dictionary another uses, and "the file's dictionary is used by
868                // nothing" is the presence rule below, not this one.
869                (0, _) => {}
870                (id, Some(z)) if id == z.dict_id => dictionary_used = true,
871                // The frames' half of the agreement: with no `ZDIC` in the file
872                // they must all report none, and none of them may name a
873                // dictionary the file does not carry.
874                (id, None) => {
875                    return Err(Error::new(
876                        Check::LayrDictIdAbsent,
877                        format!(
878                            "the frame at directory index {index} reports dictionary {id} but \
879                             the file carries no ZDIC"
880                        ),
881                    ))
882                }
883                (id, Some(z)) => {
884                    return Err(Error::new(
885                        Check::LayrDictIdMatch,
886                        format!(
887                            "the frame at directory index {index} reports dictionary {id}, ZDIC \
888                             declares {}",
889                            z.dict_id
890                        ),
891                    ))
892                }
893            }
894        }
895        if !unreadable && zdic.is_some() && !dictionary_used {
896            return Err(Error::new(
897                Check::PresenceZdic,
898                "ZDIC is present but no LAYR frame uses a dictionary",
899            ));
900        }
901
902        // The `LTBL` rules that need the table and the declared output lengths.
903        let mut slices: Vec<(u32, u64, u64)> = Vec::with_capacity(ltbl.entries.len());
904        let mut references: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
905        for (i, entry) in ltbl.entries.iter().enumerate() {
906            // Every entry names a `LAYR` chunk, the ones with no run included:
907            // section 4.9 has no null convention for this field, unlike
908            // `first_lrov`.
909            if self.layr_descriptor(entry.first_layr).is_err() {
910                return Err(Error::new(
911                    Check::LtblFirstLayrInRange,
912                    format!(
913                        "entry {i} names directory index {} for its run, which is not a LAYR chunk",
914                        entry.first_layr
915                    ),
916                ));
917            }
918            if let Some(size) = output_size
919                .get(entry.first_layr as usize)
920                .copied()
921                .flatten()
922            {
923                let end = entry.data_offset.saturating_add(u64::from(entry.data_size));
924                if end > size {
925                    return Err(Error::new(
926                        Check::LtblOffsetWithinChunk,
927                        format!(
928                            "entry {i} claims {} bytes at offset {}, past its chunk's {size} \
929                             bytes",
930                            entry.data_size, entry.data_offset
931                        ),
932                    ));
933                }
934                if !entry.is_empty() {
935                    slices.push((entry.first_layr, entry.data_offset, end));
936                }
937            }
938            if entry.first_lrov != 0 {
939                let is_lrov = self
940                    .dir
941                    .descriptors
942                    .get(entry.first_lrov as usize)
943                    .is_some_and(|d| !d.is_null() && d.chunk_type == Tag::LROV);
944                if !is_lrov {
945                    return Err(Error::new(
946                        Check::LtblFirstLrovInRange,
947                        format!(
948                            "entry {i} names directory index {} for its overrides, which is not \
949                             an LROV chunk",
950                            entry.first_lrov
951                        ),
952                    ));
953                }
954                *references.entry(entry.first_lrov).or_insert(0) += 1;
955            }
956        }
957
958        // Slices of one chunk do not overlap (section 4.9).
959        slices.sort_unstable();
960        for pair in slices.windows(2) {
961            if pair[0].0 == pair[1].0 && pair[1].1 < pair[0].2 {
962                return Err(Error::new(
963                    Check::LtblSlicesDisjoint,
964                    format!(
965                        "two slices of the chunk at directory index {} overlap: {}..{} and \
966                         {}..{}",
967                        pair[0].0, pair[0].1, pair[0].2, pair[1].1, pair[1].2
968                    ),
969                ));
970            }
971        }
972
973        // An entry's `0` says that `(layer, sector)` has no overrides. What
974        // contradicts it is an override set nothing applies: an `LROV` chunk no
975        // entry names whose payload no named chunk also carries
976        // (`ltbl.first_lrov_null`). A chunk no entry names whose payload a named
977        // chunk *does* carry contradicts nothing - those values are applied at
978        // the pair that names their twin - so that file is the orphan case below
979        // rather than this one.
980        let mut named_payloads: Vec<Vec<u8>> = Vec::new();
981        let mut unnamed: Vec<usize> = Vec::new();
982        for (index, descriptor) in self.dir.descriptors.iter().enumerate() {
983            if descriptor.is_null() || descriptor.chunk_type != Tag::LROV {
984                continue;
985            }
986            if references.get(&(index as u32)).copied().unwrap_or(0) > 0 {
987                if let Ok(Some(plain)) = self.plaintext(descriptor) {
988                    named_payloads.push(plain);
989                }
990            } else {
991                unnamed.push(index);
992            }
993        }
994        for index in unnamed {
995            let descriptor = &self.dir.descriptors[index];
996            let Ok(Some(plain)) = self.plaintext(descriptor) else {
997                // Sealed and unreadable without a key: this reader cannot tell
998                // whether the payload is applied elsewhere, so it does not claim
999                // the entry's `0` is a lie.
1000                continue;
1001            };
1002            if !named_payloads.contains(&plain) {
1003                return Err(Error::new(
1004                    Check::LtblFirstLrovNull,
1005                    format!(
1006                        "the LROV chunk at directory index {index} carries overrides that no entry \
1007                         applies, so an entry's first_lrov of 0 does not hold"
1008                    ),
1009                ));
1010            }
1011        }
1012
1013        // Every LROV chunk is referenced by at least one entry (`lrov.orphan`). A
1014        // chunk several entries name is an override set that covers all of them -
1015        // how a range, or any set of pairs sharing one delta, is written (4.5) - so
1016        // only a chunk nobody names fails here.
1017        for (index, descriptor) in self.dir.descriptors.iter().enumerate() {
1018            if descriptor.is_null() || descriptor.chunk_type != Tag::LROV {
1019                continue;
1020            }
1021            if references.get(&(index as u32)).copied().unwrap_or(0) == 0 {
1022                return Err(Error::new(
1023                    Check::LrovOrphan,
1024                    format!("the LROV chunk at directory index {index} is referenced by no entry"),
1025                ));
1026            }
1027        }
1028
1029        // Every (layer, sector)'s REE stream, one chunk at a time.
1030        for (index, _) in &chunks {
1031            if output_size
1032                .get(*index as usize)
1033                .copied()
1034                .flatten()
1035                .is_none()
1036            {
1037                continue;
1038            }
1039            self.with_chunk(*index, dict_bytes, |plain| {
1040                for (i, entry) in ltbl.entries.iter().enumerate() {
1041                    if entry.first_layr != *index || entry.is_empty() {
1042                        continue;
1043                    }
1044                    let start = entry.data_offset as usize;
1045                    let end = start.checked_add(entry.data_size as usize).ok_or_else(|| {
1046                        Error::new(Check::LtblOffsetWithinChunk, "the slice range overflows")
1047                    })?;
1048                    let data = plain.get(start..end).ok_or_else(|| {
1049                        Error::new(
1050                            Check::LtblOffsetWithinChunk,
1051                            format!("entry {i} runs past its chunk's decompressed output"),
1052                        )
1053                    })?;
1054                    self.check_layer_stream(i, data, total_pixels)?;
1055                }
1056                Ok(())
1057            })?;
1058        }
1059
1060        self.check_partition(ltbl, total_pixels, dict_bytes)
1061    }
1062
1063    /// The REE rules for one (layer, sector)'s stored bytes.
1064    fn check_layer_stream(&self, i: usize, data: &[u8], total_pixels: u32) -> Result<()> {
1065        let used = ree::validate_stream(data, total_pixels, self.level.is_strict())?;
1066        if used != data.len() {
1067            return Err(Error::new(
1068                Check::ReeNoTrailingBytes,
1069                format!(
1070                    "entry {i} stores {} bytes but its stream uses {used}",
1071                    data.len()
1072                ),
1073            ));
1074        }
1075        Ok(())
1076    }
1077
1078    /// Section 7.3's strict rule that one layer's sectors do not overlap.
1079    ///
1080    /// A layer's sectors live in different chunks now, so this is a walk over
1081    /// layers rather than over one chunk's plaintext: it holds two bitmaps, reused
1082    /// across layers, plus whatever the chunk cache holds. It reads the pixels a
1083    /// slice exposes off the stream rather than out of a decoded mask, which is
1084    /// what keeps a 16K layer's four sectors from costing four 94 MB buffers.
1085    /// The rule's other half - that the sectors cover every pixel - is what
1086    /// "their union is exactly the layer's exposed image" means, and the exposed
1087    /// image need not be every pixel, so coverage is not enforced here.
1088    fn check_partition(
1089        &self,
1090        ltbl: &LayerTable,
1091        total_pixels: u32,
1092        dictionary: Option<&[u8]>,
1093    ) -> Result<()> {
1094        if !self.level.is_strict() || !self.layer_chunks_readable() {
1095            return Ok(());
1096        }
1097        // `covered` is what the layer's earlier sectors exposed, `mine` what the
1098        // sector being walked has. A split stream reports a pixel twice - once
1099        // from the core's run and once from the overlay that carries it - so a
1100        // pixel this sector has already exposed is not another sector's. Both are
1101        // allocated at the first layer that has more than one sector: a file
1102        // whose layers each carry one never pays for them.
1103        let words = (total_pixels as usize).div_ceil(64);
1104        let mut covered: Vec<u64> = Vec::new();
1105        let mut mine: Vec<u64> = Vec::new();
1106        for layer in 0..ltbl.layer_count {
1107            let carrying: Vec<&LayerEntry> = ltbl
1108                .layer_entries(layer)
1109                .iter()
1110                .filter(|entry| !entry.is_empty())
1111                .collect();
1112            if carrying.len() < 2 {
1113                continue;
1114            }
1115            if covered.len() != words {
1116                covered.resize(words, 0);
1117                mine.resize(words, 0);
1118            }
1119            covered.fill(0);
1120            for entry in carrying {
1121                mine.fill(0);
1122                let sector = entry.sector_id;
1123                let data = self.slice_of(entry, dictionary)?;
1124                ree::exposed_ranges(&data, total_pixels, |start, end| {
1125                    expose(&mut covered, &mut mine, start, end, layer, sector)
1126                })?;
1127            }
1128        }
1129        Ok(())
1130    }
1131
1132    /// Whether every `LAYR` frame can be read.
1133    ///
1134    /// A sealed file without a key cannot have its frames opened, and the checks
1135    /// that need their content are skipped rather than guessed at.
1136    fn layer_chunks_readable(&self) -> bool {
1137        self.key.is_some() || self.layr_chunks().iter().all(|(_, d)| !d.is_encrypted())
1138    }
1139
1140    /// `LHAS`: the Merkle root always, and every leaf in strict mode.
1141    fn check_hashes(&self, head: &Head) -> Result<Option<LayerHashes>> {
1142        let Some(d) = self.dir.find(Tag::LHAS) else {
1143            return Ok(None);
1144        };
1145        let payload = self
1146            .plaintext(d)?
1147            .expect("LHAS is never sealed, so it is always readable");
1148        let hashes = LayerHashes::parse(&payload)?;
1149        if hashes.layer_count != head.total_layers {
1150            return Err(Error::new(
1151                Check::LhasLayerCount,
1152                format!(
1153                    "LHAS covers {} layers, HEAD says {}",
1154                    hashes.layer_count, head.total_layers
1155                ),
1156            ));
1157        }
1158        let recomputed = lhas::merkle_root(&hashes.layer_hashes);
1159        if recomputed != hashes.merkle_root {
1160            return Err(Error::new(
1161                Check::LhasRootRecompute,
1162                "the stored Merkle root is not the root of the stored leaf hashes",
1163            ));
1164        }
1165        Ok(Some(hashes))
1166    }
1167
1168    /// Section 11.3's strict rule that each layer hashes to its stored leaf.
1169    ///
1170    /// A layer's leaf covers its sectors' slices concatenated in ascending
1171    /// `sector_id` (section 4.10), which is what a reader reconstructs. This is a
1172    /// second pass over the chunks, so that the REE checks keep their place in the
1173    /// check order.
1174    fn check_leaves(
1175        &self,
1176        ltbl: &Option<LayerTable>,
1177        zdic: &Option<ZstdDictionary>,
1178        hashes: &LayerHashes,
1179    ) -> Result<()> {
1180        if !self.level.is_strict() || !self.layer_chunks_readable() {
1181            return Ok(());
1182        }
1183        let Some(ltbl) = ltbl.as_ref() else {
1184            return Ok(());
1185        };
1186        let dict_bytes = zdic.as_ref().map(|z| z.dict_bytes.as_slice());
1187        for layer in 0..ltbl.layer_count {
1188            let Some(stored_leaf) = hashes.layer_hashes.get(layer as usize) else {
1189                continue;
1190            };
1191            // An empty layer stores no bytes, so it hashes the empty slice.
1192            let data = self.layer_bytes(ltbl.layer_entries(layer), dict_bytes)?;
1193            if lhas::leaf_hash(&data) != *stored_leaf {
1194                return Err(Error::new(
1195                    Check::LhasLeafMatch,
1196                    format!("layer {layer} does not hash to its stored leaf"),
1197                ));
1198            }
1199        }
1200        Ok(())
1201    }
1202
1203    /// Section 11.4's final row: every sealed unit's tag must verify.
1204    ///
1205    /// A `LAYR` frame is its own unit, bound by its associated data to the
1206    /// chunk's directory index, so opening it is what proves that binding.
1207    fn check_sealed_units(&self, key: &SessionKey, cipher: Cipher) -> Result<()> {
1208        for (index, d) in self.dir.descriptors.iter().enumerate() {
1209            if d.is_null() || d.flags & CHUNK_FLAG_ENCRYPTED == 0 {
1210                continue;
1211            }
1212            if d.chunk_type == Tag::LAYR {
1213                self.layr_frame(index as u32, d)?;
1214            } else {
1215                crypto::open(cipher, key, d.chunk_type, 0, self.stored(d)?)?;
1216            }
1217        }
1218        Ok(())
1219    }
1220}
1221
1222fn json_error(err: serde_json::Error) -> Error {
1223    Error::new(Check::MetaJson, err.to_string())
1224}
1225
1226/// Enforce the integer rule for the durations named in `names`.
1227///
1228/// A duration is an exact whole number of milliseconds, so a value with a
1229/// fractional part is a type violation rather than a range one, and a loose
1230/// reader rejects it too (section 11.5). Reading the raw numbers is what makes
1231/// the named check the *first* failure: a fractional duration cannot
1232/// deserialize into the integer field it belongs to, so the typed parse would
1233/// report a generic JSON error instead. A reader MAY also reject integral
1234/// floating-point syntax, and this one does: `2500.0` is written as a
1235/// floating-point number, and a negative or too-large one is no more a
1236/// millisecond count this format can express.
1237fn check_integer_durations<'a>(
1238    object: &Map<String, Value>,
1239    names: impl Iterator<Item = &'a str>,
1240    check: Check,
1241) -> Result<()> {
1242    for name in names {
1243        let Some(value) = object.get(name) else {
1244            continue;
1245        };
1246        // A non-number is a shape error another check owns. A number that is not
1247        // a whole count inside the field's range is not a duration this format
1248        // can express: `2500.0` has no `as_u64`, and neither has a negative or
1249        // an over-large value.
1250        if value.is_number() && value.as_u64().is_none_or(|n| n > u64::from(u32::MAX)) {
1251            return Err(Error::new(
1252                check,
1253                format!("{name} is {value}, which is not a whole number of milliseconds"),
1254            ));
1255        }
1256    }
1257    Ok(())
1258}
1259
1260/// `HEAD` rules from sections 11.1 and 11.2.
1261fn check_hdr(head: &Head) -> Result<()> {
1262    if head.total_layers == 0 {
1263        return Err(Error::new(
1264            Check::HeadTotalLayers,
1265            "HEAD declares no layers",
1266        ));
1267    }
1268    if head.layer_height_um == 0 {
1269        return Err(Error::new(
1270            Check::HeadLayerHeight,
1271            "layer_height_um is zero",
1272        ));
1273    }
1274    if head.build_width_um == 0 || head.build_depth_um == 0 || head.build_height_um == 0 {
1275        return Err(Error::new(
1276            Check::HeadBuildDims,
1277            "a build dimension is zero",
1278        ));
1279    }
1280    if head.total_pixels() == 0 {
1281        return Err(Error::new(
1282            Check::HeadDisplayPixels,
1283            "the display has no pixels",
1284        ));
1285    }
1286    Ok(())
1287}
1288
1289/// Every PWM a parsed payload carries is a duty over `0..=255` (section 4.2).
1290///
1291/// The range is checked rather than clamped: a duty of `3000` is a slicer that has
1292/// mistaken a percentage or a wider scale for the field, and silently clamping it
1293/// would print at a different power from a reader that used it as written.
1294fn check_pwm(timing: &Timing, at: &str) -> Result<()> {
1295    for (name, value) in [
1296        ("light_pwm", timing.light_pwm),
1297        ("bottom_light_pwm", timing.bottom_light_pwm),
1298    ] {
1299        if let Some(value) = value {
1300            if value > PWM_MAX {
1301                return Err(Error::new(
1302                    Check::PwmRange,
1303                    format!("{at}: {name} is {value}, outside 0..={PWM_MAX}"),
1304                ));
1305            }
1306        }
1307    }
1308    Ok(())
1309}
1310
1311/// [`check_pwm`] for a payload that is still raw JSON, which is how an `LROV`
1312/// delta is read: it is a sparse object, not a `Timing`.
1313fn check_pwm_object(object: &Map<String, Value>, at: &str) -> Result<()> {
1314    for name in ["light_pwm", "bottom_light_pwm"] {
1315        let Some(value) = object.get(name) else {
1316            continue;
1317        };
1318        if value.is_null() {
1319            continue;
1320        }
1321        if !value
1322            .as_u64()
1323            .is_some_and(|duty| duty <= u64::from(PWM_MAX))
1324        {
1325            return Err(Error::new(
1326                Check::PwmRange,
1327                format!("{at}: {name} is {value}, outside the integer range 0..={PWM_MAX}"),
1328            ));
1329        }
1330    }
1331    Ok(())
1332}
1333
1334/// `META` rules from section 11.2.
1335fn check_meta(meta: &Meta) -> Result<()> {
1336    match meta.meta_version {
1337        Some(1) => {}
1338        Some(other) => {
1339            return Err(Error::new(
1340                Check::MetaVersion,
1341                format!("unsupported meta_version {other}"),
1342            ))
1343        }
1344        None => return Err(Error::new(Check::MetaVersion, "no meta_version")),
1345    }
1346    let timing = &meta.timing;
1347    if timing.normal_exposure_ms.unwrap_or(0) == 0 {
1348        return Err(Error::new(
1349            Check::MetaExposure,
1350            "normal_exposure_ms is not positive",
1351        ));
1352    }
1353    if timing.bottom_exposure_ms.unwrap_or(0) == 0 {
1354        return Err(Error::new(
1355            Check::MetaExposure,
1356            "bottom_exposure_ms is not positive",
1357        ));
1358    }
1359    if timing.layer_height_um.unwrap_or(0) == 0 {
1360        return Err(Error::new(
1361            Check::MetaLayerHeight,
1362            "layer_height_um is not positive",
1363        ));
1364    }
1365    if let Some(materials) = meta.materials.as_ref() {
1366        check_materials(materials, Check::MetaMaterialsShape)?;
1367        // A sector's material index is bounded against the library the same META
1368        // carries; a print with no library has nothing to bound against.
1369        for sector in meta.sectors.as_deref().unwrap_or_default() {
1370            if let Some(index) = sector.material_index {
1371                if index as usize >= materials.len() {
1372                    return Err(Error::new(
1373                        Check::MetaSectorMaterialIndex,
1374                        format!(
1375                            "sector {} names material {index}, which the material library does \
1376                             not contain",
1377                            sector.sector_id
1378                        ),
1379                    ));
1380                }
1381            }
1382        }
1383    }
1384    if let Some(curve) = timing.cure_curve.as_ref() {
1385        check_cure_curve(curve, Check::MetaCureCurve)?;
1386    }
1387    check_pwm(timing, "META")?;
1388    for sector in meta.sectors.as_deref().unwrap_or_default() {
1389        check_pwm(
1390            &sector.timing,
1391            &format!("META.sectors entry for sector {}", sector.sector_id),
1392        )?;
1393    }
1394    for temperature in [timing.chamber_temperature_c, timing.vat_temperature_c]
1395        .into_iter()
1396        .flatten()
1397    {
1398        if !(0.0..=120.0).contains(&temperature) {
1399            return Err(Error::new(
1400                Check::MetaTemperatureRange,
1401                format!("temperature {temperature} is outside 0..=120 degrees Celsius"),
1402            ));
1403        }
1404    }
1405    Ok(())
1406}
1407
1408/// `PROF` rules from section 11.2.
1409fn check_profile(profile: &Profile) -> Result<()> {
1410    match profile.profile_type.as_deref() {
1411        Some("material") | Some("printer") | Some("combined") => {}
1412        Some(other) => {
1413            return Err(Error::new(
1414                Check::ProfProfileType,
1415                format!("unknown profile_type {other:?}"),
1416            ))
1417        }
1418        None => return Err(Error::new(Check::ProfProfileType, "no profile_type")),
1419    }
1420    if profile.profile_name.is_empty() || profile.profile_version.is_empty() {
1421        return Err(Error::new(
1422            Check::ProfProfileIdentity,
1423            "profile_name and profile_version must be non-empty",
1424        ));
1425    }
1426    if profile.settings.normal_exposure_ms.unwrap_or(0) == 0
1427        || profile.settings.bottom_exposure_ms.unwrap_or(0) == 0
1428    {
1429        return Err(Error::new(
1430            Check::ProfSettingsExposure,
1431            "a profile exposure time is not positive",
1432        ));
1433    }
1434    if profile.settings.layer_height_um.unwrap_or(0) == 0 {
1435        return Err(Error::new(
1436            Check::ProfSettingsLayerHeight,
1437            "the profile's layer_height_um is not positive",
1438        ));
1439    }
1440    check_pwm(&profile.settings, "PROF.settings")?;
1441    if let Some(curve) = profile.settings.cure_curve.as_ref() {
1442        check_cure_curve(curve, Check::ProfCureCurve)?;
1443    }
1444    if let Some(uuid) = profile.profile_uuid.as_deref() {
1445        if !is_uuid(uuid) {
1446            return Err(Error::new(
1447                Check::ProfProfileUuid,
1448                format!("{uuid:?} is not a UUID"),
1449            ));
1450        }
1451    }
1452    if let Some(materials) = profile.materials.as_ref() {
1453        check_materials(materials, Check::ProfMaterialsShape)?;
1454    }
1455    Ok(())
1456}
1457
1458/// A materials array must be non-empty and every entry must be named.
1459fn check_materials(materials: &[Material], check: Check) -> Result<()> {
1460    if materials.is_empty() {
1461        return Err(Error::new(check, "the array is empty"));
1462    }
1463    if let Some(index) = materials.iter().position(|m| m.name.is_empty()) {
1464        return Err(Error::new(check, format!("material {index} has no name")));
1465    }
1466    Ok(())
1467}
1468
1469/// `META.sectors`: an array of objects, each with a unique `sector_id` >= 1.
1470///
1471/// Read from the raw JSON so the named check is the *first* failure: an id that
1472/// is absent or not an integer cannot deserialize into the entry's field, and the
1473/// typed parse would report a generic JSON error instead. The durations inside an
1474/// entry are checked here too, under META's own integer rule, because they are
1475/// META's fields in the same namespace.
1476fn check_sectors_shape(object: &Map<String, Value>) -> Result<()> {
1477    let Some(sectors) = object.get("sectors") else {
1478        return Ok(());
1479    };
1480    let Some(entries) = sectors.as_array() else {
1481        return Err(Error::new(
1482            Check::MetaSectorsShape,
1483            "META.sectors is not an array",
1484        ));
1485    };
1486    let mut seen: Vec<u64> = Vec::with_capacity(entries.len());
1487    for (i, entry) in entries.iter().enumerate() {
1488        let Some(entry) = entry.as_object() else {
1489            return Err(Error::new(
1490                Check::MetaSectorsShape,
1491                format!("META.sectors[{i}] is not an object"),
1492            ));
1493        };
1494        let Some(id) = entry.get("sector_id").and_then(Value::as_u64) else {
1495            return Err(Error::new(
1496                Check::MetaSectorsShape,
1497                format!("META.sectors[{i}] carries no integer sector_id"),
1498            ));
1499        };
1500        if id == 0 {
1501            return Err(Error::new(
1502                Check::MetaSectorsShape,
1503                format!(
1504                    "META.sectors[{i}] uses sector_id 0, which names the implicit primary sector"
1505                ),
1506            ));
1507        }
1508        if id > u64::from(u32::MAX) {
1509            return Err(Error::new(
1510                Check::MetaSectorsShape,
1511                format!("META.sectors[{i}] uses sector_id {id}, which does not fit a u32"),
1512            ));
1513        }
1514        if seen.contains(&id) {
1515            return Err(Error::new(
1516                Check::MetaSectorsShape,
1517                format!("sector_id {id} appears twice"),
1518            ));
1519        }
1520        seen.push(id);
1521        check_integer_durations(
1522            entry,
1523            TIME_MS_FIELDS.iter().copied(),
1524            Check::MetaTimeInteger,
1525        )?;
1526    }
1527    Ok(())
1528}
1529
1530/// A cure curve must be physically meaningful, and a NaN is not a number the
1531/// Beer-Lambert model can use, so each bound rejects it explicitly rather than
1532/// relying on how a negated comparison treats an incomparable value.
1533fn check_cure_curve(curve: &crate::json::CureCurve, check: Check) -> Result<()> {
1534    if curve.dp_um.is_nan() || curve.dp_um <= 0.0 {
1535        return Err(Error::new(check, "dp_um is not positive"));
1536    }
1537    if curve.ec_mj_cm2.is_nan() || curve.ec_mj_cm2 <= 0.0 {
1538        return Err(Error::new(check, "ec_mj_cm2 is not positive"));
1539    }
1540    if curve.e0_mj_cm2.is_nan() || curve.e0_mj_cm2 < 0.0 {
1541        return Err(Error::new(check, "e0_mj_cm2 is negative"));
1542    }
1543    Ok(())
1544}
1545
1546fn is_uuid(text: &str) -> bool {
1547    let bytes = text.as_bytes();
1548    if bytes.len() != 36 {
1549        return false;
1550    }
1551    for (i, b) in bytes.iter().enumerate() {
1552        let dashed = matches!(i, 8 | 13 | 18 | 23);
1553        if dashed {
1554            if *b != b'-' {
1555                return false;
1556            }
1557        } else if !b.is_ascii_hexdigit() {
1558            return false;
1559        }
1560    }
1561    true
1562}
1563
1564/// `EXTD` rules from sections 11.2 and 4.12.
1565fn check_extension(ext: &Extension) -> Result<()> {
1566    if ext.critical && !ext.is_implemented() {
1567        return Err(Error::new(
1568            Check::ExtdCritical,
1569            format!(
1570                "extension {}{:?} is critical and unimplemented",
1571                ext.tag(),
1572                ext.vendor_id
1573            ),
1574        ));
1575    }
1576    Ok(())
1577}
1578
1579/// The reserved-flag and critical rules, for an extension whose payload cannot
1580/// be read.
1581fn check_opaque_extension(flags: u32) -> Result<()> {
1582    if flags & EXTD_RESERVED_MASK != 0 {
1583        return Err(Error::new(
1584            Check::ExtdFlags,
1585            format!("EXTD flags {flags:#010x} set a reserved bit"),
1586        ));
1587    }
1588    if flags & CRITICAL_BIT != 0 {
1589        return Err(Error::new(
1590            Check::ExtdCritical,
1591            "a critical extension is unimplemented",
1592        ));
1593    }
1594    Ok(())
1595}