Skip to main content

mf2_catalog/
reader.rs

1//! The client reader: [`Catalog::new`] validates the structure once, in one
2//! linear pass per section (F4); every accessor after that is a bounds-checked
3//! O(1) read (O(log n) for [`Catalog::fallback_locale`] and
4//! [`Catalog::lookup`]) that never panics. The fetched buffer *is* the
5//! catalog: nothing is copied and nothing is allocated (F2).
6
7use alloc::vec::Vec;
8
9use mf2_model::{Dir, MsgId};
10
11use crate::bytes::{Cur, nul_pos, plane_entry, u16_at, u32_at, u64_at};
12use crate::error::CatalogError;
13use crate::format::{
14    HEADER_LEN, IDS_RESTART, MAGIC, MAX_FALLBACK_LOCALES, MAX_MESSAGES, SECTION_ENTRY_LEN,
15    VERSION_MAJOR, flags, header, kind, locale_key, section,
16};
17use crate::plural;
18use crate::view::{MsgView, Names};
19
20/// An opaque reference to a catalog string; [`Catalog::text`] resolves it.
21///
22/// Nothing outside this crate may assume what it holds (a seam kept for
23/// catalog text as JS strings, `plans/stretch_goals_after_v1`).
24#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
25pub struct StrRef(pub(crate) u32);
26
27/// The CLDR version of a catalog's locale data.
28#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
29pub struct CldrVersion {
30    /// The release (`48` of CLDR 48.2.1).
31    pub major: u16,
32    /// The minor version (`2`).
33    pub minor: u8,
34    /// The patch (`1`).
35    pub patch: u8,
36}
37
38impl CldrVersion {
39    /// The header encoding: `major << 16 | minor << 8 | patch`.
40    #[doc(hidden)]
41    pub const fn to_u32(self) -> u32 {
42        (self.major as u32) << 16 | (self.minor as u32) << 8 | self.patch as u32
43    }
44
45    /// From the header encoding; `None` for 0 (no CLDR data).
46    #[allow(clippy::cast_possible_truncation)] // the fields are bit ranges of `v`
47    #[doc(hidden)]
48    pub const fn from_u32(v: u32) -> Option<Self> {
49        if v == 0 {
50            return None;
51        }
52        Some(CldrVersion {
53            major: (v >> 16) as u16,
54            minor: (v >> 8) as u8,
55            patch: v as u8,
56        })
57    }
58}
59
60/// One INDEX lookup.
61#[derive(Clone, Copy, Debug)]
62pub enum Entry<'a> {
63    /// A single text run: resolve with [`Catalog::text`]. The evaluator is
64    /// not entered.
65    Simple(StrRef),
66    /// One pattern with placeholders or markup, maybe declarations.
67    Pattern(MsgView<'a>),
68    /// A `.match` message.
69    Select(MsgView<'a>),
70    /// The manifest has the id; this catalog (or chunk) does not carry it.
71    Absent,
72}
73
74/// Offset and length of a section inside the buffer.
75#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
76pub(crate) struct Span {
77    pub(crate) off: usize,
78    pub(crate) len: usize,
79}
80
81impl Span {
82    #[inline]
83    pub(crate) fn of<'a>(&self, b: &'a [u8]) -> &'a [u8] {
84        match self.off.checked_add(self.len) {
85            Some(end) => b.get(self.off..end).unwrap_or(&[]),
86            None => &[],
87        }
88    }
89}
90
91/// A validated `.mf2b` catalog (F2): the fetched buffer and the offsets
92/// `new` found. Share it as `Rc<Catalog>` / `Arc<Catalog>`.
93pub struct Catalog {
94    bytes: Bytes,
95    version: u16,
96    flags: u16,
97    hash: u64,
98    count: u32,
99    locale: u32,
100    cldr: u32,
101    chunk: u8,
102    dir: Dir,
103    pub(crate) index: Span,
104    pub(crate) messages: Span,
105    /// Read by the decoder only (the client never reads COLD).
106    #[cfg_attr(not(feature = "decode"), allow(dead_code))]
107    pub(crate) cold: Option<Span>,
108    pub(crate) names: Span,
109    fallback: Option<Fallback>,
110    locale_sec: Span,
111    plural: [Option<Span>; 2],
112    funcs: Span,
113    ids: Option<Span>,
114    pub(crate) strings: Span,
115}
116
117/// A catalog's buffer. Without `static-bytes` it is the fetched `Vec`, as
118/// it always was, so the client — which never turns the feature on — pays
119/// nothing for it (`cargo xtask size`, 2026-09-27: the reference app's raw
120/// wasm 6 bytes smaller; an unconditional owned-or-static enum cost it
121/// 392).
122#[cfg(not(feature = "static-bytes"))]
123type Bytes = Vec<u8>;
124
125/// A catalog's buffer: fetched (owned), or part of the program itself (an
126/// embedded catalog, never copied) — `static-bytes`, which only a native
127/// application turns on.
128#[cfg(feature = "static-bytes")]
129enum Bytes {
130    Owned(Vec<u8>),
131    Static(&'static [u8]),
132}
133
134#[cfg(feature = "static-bytes")]
135impl Bytes {
136    fn as_slice(&self) -> &[u8] {
137        match self {
138            Bytes::Owned(v) => v,
139            Bytes::Static(b) => b,
140        }
141    }
142}
143
144#[cfg(feature = "static-bytes")]
145impl core::ops::Deref for Bytes {
146    type Target = [u8];
147
148    fn deref(&self) -> &[u8] {
149        self.as_slice()
150    }
151}
152
153/// FALLBACK: the locale table (`str32` × n) and the entries (`u32` each).
154#[derive(Clone, Copy)]
155struct Fallback {
156    locales: Span,
157    entries: Span,
158}
159
160impl core::fmt::Debug for Catalog {
161    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
162        f.debug_struct("Catalog")
163            .field("locale", &self.locale())
164            .field("messages", &self.count)
165            .field("bytes", &self.bytes.len())
166            .finish_non_exhaustive()
167    }
168}
169
170/// The known sections found by the table walk.
171#[derive(Default)]
172struct Found {
173    index: Option<Span>,
174    messages: Option<Span>,
175    cold: Option<Span>,
176    names: Option<Span>,
177    fallback: Option<Span>,
178    locale: Option<Span>,
179    funcs: Option<Span>,
180    ids: Option<Span>,
181    strings: Option<Span>,
182}
183
184impl Found {
185    fn slot(&mut self, kind: u16) -> Option<&mut Option<Span>> {
186        Some(match kind {
187            section::INDEX => &mut self.index,
188            section::MESSAGES => &mut self.messages,
189            section::COLD => &mut self.cold,
190            section::NAMES => &mut self.names,
191            section::FALLBACK => &mut self.fallback,
192            section::LOCALE => &mut self.locale,
193            section::FUNCS => &mut self.funcs,
194            section::IDS => &mut self.ids,
195            section::STRINGS => &mut self.strings,
196            _ => return None,
197        })
198    }
199}
200
201impl Catalog {
202    /// Takes ownership of a fetched `.mf2b` buffer and validates its
203    /// structure once: magic, version (F9), `manifest_hash` (F6), the section
204    /// table, INDEX bounds and monotonicity, and the NAMES, FUNCS, FALLBACK,
205    /// LOCALE (with its plural entries) and IDS tables. Strings are checked
206    /// when read (F4). Linear in the buffer; allocates nothing; no copy (F2).
207    pub fn new(bytes: Vec<u8>, expect_manifest: u64) -> Result<Catalog, CatalogError> {
208        #[cfg(feature = "static-bytes")]
209        let bytes = Bytes::Owned(bytes);
210        Catalog::validate(bytes, expect_manifest)
211    }
212
213    /// As [`Catalog::new`], over bytes that live as long as the program —
214    /// a catalog embedded with `include_bytes!` — without copying them
215    /// (feature `static-bytes`).
216    #[cfg(feature = "static-bytes")]
217    pub fn from_static(
218        bytes: &'static [u8],
219        expect_manifest: u64,
220    ) -> Result<Catalog, CatalogError> {
221        Catalog::validate(Bytes::Static(bytes), expect_manifest)
222    }
223
224    // Always inlined: `new` is the client's only caller, and a separate
225    // function costs its wasm (`cargo xtask size`).
226    #[inline(always)]
227    #[allow(clippy::inline_always)]
228    fn validate(bytes: Bytes, expect_manifest: u64) -> Result<Catalog, CatalogError> {
229        let b = bytes.as_slice();
230        if b.get(..4) != Some(&MAGIC[..]) {
231            return Err(CatalogError::Magic);
232        }
233        let version = u16_at(b, header::VERSION).ok_or(CatalogError::Truncated)?;
234        if version >> 8 != VERSION_MAJOR {
235            return Err(CatalogError::Version);
236        }
237        if b.len() < HEADER_LEN {
238            return Err(CatalogError::Truncated);
239        }
240        let hash = u64_at(b, header::MANIFEST_HASH).ok_or(CatalogError::Truncated)?;
241        if hash != expect_manifest {
242            return Err(CatalogError::ManifestMismatch);
243        }
244        let get16 = |at| u16_at(b, at).ok_or(CatalogError::Truncated);
245        let get32 = |at| u32_at(b, at).ok_or(CatalogError::Truncated);
246        let flags = get16(header::FLAGS)?;
247        let count = get32(header::MESSAGE_COUNT)?;
248        let locale = get32(header::LOCALE)?;
249        let cldr = get32(header::CLDR_VERSION)?;
250        let chunk = *b.get(header::CHUNK).ok_or(CatalogError::Truncated)?;
251        let dir = match b.get(header::DIR) {
252            Some(0) => Dir::Ltr,
253            Some(1) => Dir::Rtl,
254            _ => return Err(CatalogError::Header),
255        };
256        if count > MAX_MESSAGES {
257            return Err(CatalogError::Header);
258        }
259        let found = sections(b)?;
260        let need = |s: Option<Span>| s.ok_or(CatalogError::MissingSection);
261        let index = need(found.index)?;
262        let messages = need(found.messages)?;
263        let names = need(found.names)?;
264        let locale_sec = need(found.locale)?;
265        let funcs = need(found.funcs)?;
266        let strings = need(found.strings)?;
267        let pool = strings.of(b);
268        if pool.last() != Some(&0) {
269            return Err(CatalogError::Strings);
270        }
271        let in_pool = |r: u32| (r as usize) < pool.len();
272        if !in_pool(locale) {
273            return Err(CatalogError::Header);
274        }
275        check_index(index.of(b), count as usize, messages.len, pool.len())?;
276        check_names(names.of(b), pool.len()).ok_or(CatalogError::Names)?;
277        check_funcs(funcs.of(b), pool.len()).ok_or(CatalogError::Funcs)?;
278        let fallback = match found.fallback {
279            Some(s) => Some(check_fallback(b, s, count, pool.len()).ok_or(CatalogError::Fallback)?),
280            None => None,
281        };
282        let plural = check_locale(b, locale_sec).ok_or(CatalogError::Locale)?;
283        if let Some(ids) = found.ids {
284            check_ids(ids.of(b), count as usize).ok_or(CatalogError::Ids)?;
285        }
286        Ok(Catalog {
287            version,
288            flags,
289            hash,
290            count,
291            locale,
292            cldr,
293            chunk,
294            dir,
295            index,
296            messages,
297            cold: found.cold,
298            names,
299            fallback,
300            locale_sec,
301            plural,
302            funcs,
303            ids: found.ids,
304            strings,
305            bytes,
306        })
307    }
308
309    /// `format_version`: `major << 8 | minor`.
310    #[doc(hidden)]
311    pub fn format_version(&self) -> u16 {
312        self.version
313    }
314
315    /// The manifest hash this catalog was compiled against (F6).
316    pub fn manifest_hash(&self) -> u64 {
317        self.hash
318    }
319
320    /// The BCP 47 tag (F7).
321    pub fn locale(&self) -> &str {
322        self.text(StrRef(self.locale)).unwrap_or("")
323    }
324
325    /// The locale's direction (F7): `Ltr` or `Rtl`.
326    pub fn dir(&self) -> Dir {
327        self.dir
328    }
329
330    /// The `MsgId` chunk this catalog holds (0 until chunking).
331    #[doc(hidden)]
332    pub fn chunk(&self) -> u8 {
333        self.chunk
334    }
335
336    /// The CLDR version of the locale data, if any.
337    pub fn cldr_version(&self) -> Option<CldrVersion> {
338        CldrVersion::from_u32(self.cldr)
339    }
340
341    /// Whether COLD was stripped (production catalogs, §2.3).
342    #[doc(hidden)]
343    pub fn cold_stripped(&self) -> bool {
344        self.flags & flags::COLD_STRIPPED != 0
345    }
346
347    /// Whether IDS was stripped (production catalogs, §2.3).
348    #[doc(hidden)]
349    pub fn ids_stripped(&self) -> bool {
350        self.flags & flags::IDS_STRIPPED != 0
351    }
352
353    /// The number of ids in the manifest (INDEX entries).
354    pub fn message_count(&self) -> u32 {
355        self.count
356    }
357
358    /// The raw bytes (for serving, hashing, measuring).
359    pub fn as_bytes(&self) -> &[u8] {
360        &self.bytes
361    }
362
363    /// Gives the buffer back (a copy, for a catalog over static bytes).
364    pub fn into_bytes(self) -> Vec<u8> {
365        #[cfg(not(feature = "static-bytes"))]
366        return self.bytes;
367        #[cfg(feature = "static-bytes")]
368        match self.bytes {
369            Bytes::Owned(v) => v,
370            Bytes::Static(b) => b.to_vec(),
371        }
372    }
373
374    /// The section table: `(kind, offset, length)` in file order, unknown
375    /// kinds included (for tools: sizes, dumps).
376    #[doc(hidden)]
377    pub fn sections(&self) -> impl Iterator<Item = (u16, u32, u32)> + '_ {
378        let n = u16_at(&self.bytes, header::SECTION_COUNT).unwrap_or(0);
379        (0..usize::from(n)).filter_map(move |i| {
380            let at = HEADER_LEN.checked_add(i.checked_mul(SECTION_ENTRY_LEN)?)?;
381            Some((
382                u16_at(&self.bytes, at)?,
383                u32_at(&self.bytes, at.checked_add(2)?)?,
384                u32_at(&self.bytes, at.checked_add(6)?)?,
385            ))
386        })
387    }
388
389    /// O(1) lookup by id (F3). Never fails: an id outside this catalog (or
390    /// chunk) is `Absent`.
391    #[inline]
392    #[doc(hidden)]
393    pub fn get(&self, id: MsgId) -> Entry<'_> {
394        if id.chunk() != self.chunk || id.index() >= self.count {
395            return Entry::Absent;
396        }
397        let Some(e) = plane_entry(
398            self.index.of(&self.bytes),
399            self.count as usize,
400            id.index() as usize,
401        ) else {
402            return Entry::Absent;
403        };
404        let off = e & kind::OFFSET_MASK;
405        match e >> kind::SHIFT {
406            kind::SIMPLE => Entry::Simple(StrRef(off)),
407            kind::PATTERN => Entry::Pattern(MsgView::new(self, off as usize, false)),
408            kind::SELECT => Entry::Select(MsgView::new(self, off as usize, true)),
409            _ => Entry::Absent,
410        }
411    }
412
413    /// The string `r` refers to; `None` if it is out of bounds or not valid
414    /// UTF-8 (F4: checked on access, so a corrupt string costs only the
415    /// message that uses it).
416    #[inline]
417    #[doc(hidden)]
418    pub fn text(&self, r: StrRef) -> Option<&str> {
419        let rest = self.strings.of(&self.bytes).get(r.0 as usize..)?;
420        let end = nul_pos(rest)?;
421        core::str::from_utf8(rest.get(..end)?).ok()
422    }
423
424    /// The locale a message's text came from, when it is not this catalog's
425    /// own (F7). O(log n).
426    #[doc(hidden)]
427    pub fn fallback_locale(&self, id: MsgId) -> Option<&str> {
428        let fb = self.fallback?;
429        if id.chunk() != self.chunk {
430            return None;
431        }
432        let entries = fb.entries.of(&self.bytes);
433        let target = id.index();
434        let (mut lo, mut hi) = (0usize, entries.len() / 4);
435        while lo < hi {
436            let mid = lo + (hi - lo) / 2;
437            let e = u32_at(entries, mid.checked_mul(4)?)?;
438            match (e & 0x00ff_ffff).cmp(&target) {
439                core::cmp::Ordering::Less => lo = mid + 1,
440                core::cmp::Ordering::Greater => hi = mid,
441                core::cmp::Ordering::Equal => {
442                    let loc = u32_at(fb.locales.of(&self.bytes), ((e >> 24) as usize) * 4)?;
443                    return self.text(StrRef(loc));
444                }
445            }
446        }
447        None
448    }
449
450    /// Entry `index` of FUNCS: a function identifier (`ns:name`, NFC).
451    #[doc(hidden)]
452    pub fn function(&self, index: u32) -> Option<&str> {
453        let at = (index as usize).checked_mul(4)?;
454        self.text(StrRef(u32_at(self.funcs.of(&self.bytes), at)?))
455    }
456
457    /// The number of FUNCS entries.
458    #[doc(hidden)]
459    pub fn function_count(&self) -> u32 {
460        u32::try_from(self.funcs.len / 4).unwrap_or(u32::MAX)
461    }
462
463    /// The payload of the LOCALE entry with `key` (opaque; §2.7, §4).
464    #[doc(hidden)]
465    pub fn locale_entry(&self, key: u32) -> Option<&[u8]> {
466        match key {
467            locale_key::PLURAL_CARDINAL => return self.plural[0].map(|s| s.of(&self.bytes)),
468            locale_key::PLURAL_ORDINAL => return self.plural[1].map(|s| s.of(&self.bytes)),
469            _ => {}
470        }
471        let mut c = Cur::new(self.locale_sec.of(&self.bytes), 0);
472        let n = c.varint()?;
473        for _ in 0..n {
474            let k = c.varint()?;
475            let len = c.len()?;
476            let payload = c.take(len)?;
477            if k == key {
478                return Some(payload);
479            }
480            if k > key {
481                return None;
482            }
483        }
484        None
485    }
486
487    /// A message's variable names (NAMES): its slots and its locals. Empty
488    /// for simple and absent messages.
489    #[doc(hidden)]
490    pub fn names(&self, id: MsgId) -> Names<'_> {
491        match self.get(id) {
492            Entry::Pattern(m) | Entry::Select(m) => m.names(),
493            Entry::Simple(_) | Entry::Absent => Names::EMPTY,
494        }
495    }
496
497    /// The name of message `id` (IDS); `None` when IDS is stripped or the
498    /// index is past the last message.
499    ///
500    /// Build side (feature `decode`): a client formats by `MsgId` and never
501    /// needs an id back, so this is not on its path. `mf2 dump` and the
502    /// tooling that reports on a catalog do.
503    #[cfg(feature = "decode")]
504    #[doc(hidden)]
505    pub fn id_of(&self, id: MsgId) -> Option<alloc::string::String> {
506        let index = id.index() as usize;
507        let count = self.count as usize;
508        if index >= count {
509            return None;
510        }
511        let ids = self.ids?.of(&self.bytes);
512        let blocks = count.div_ceil(IDS_RESTART);
513        let table_len = blocks.checked_mul(4)?;
514        let entries = ids.get(table_len..)?;
515        let block = index / IDS_RESTART;
516        let at = u32_at(ids, block.checked_mul(4)?)? as usize;
517        let mut c = Cur::new(entries, at);
518        // Ids are prefix-compressed against the one before them, so the id
519        // is rebuilt from the start of its restart block.
520        let mut current: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
521        for i in (block * IDS_RESTART)..=index {
522            let shared = c.len()?;
523            let len = c.len()?;
524            let suffix = c.take(len)?;
525            if shared > current.len() {
526                return None;
527            }
528            current.truncate(shared);
529            current.extend_from_slice(suffix);
530            if i == index {
531                return alloc::string::String::from_utf8(current).ok();
532            }
533        }
534        None
535    }
536
537    /// Looks a message id up by name (IDS); `None` when IDS is stripped or
538    /// the id is unknown. O(log n).
539    pub fn lookup(&self, id: &str) -> Option<MsgId> {
540        let ids = self.ids?.of(&self.bytes);
541        let count = self.count as usize;
542        let blocks = count.div_ceil(IDS_RESTART);
543        let table_len = blocks.checked_mul(4)?;
544        let entries = ids.get(table_len..)?;
545        let key = id.as_bytes();
546        // The last restart whose id is ≤ key.
547        let (mut lo, mut hi) = (0usize, blocks);
548        while lo < hi {
549            let mid = lo + (hi - lo) / 2;
550            let at = u32_at(ids, mid.checked_mul(4)?)? as usize;
551            let mut c = Cur::new(entries, at);
552            let _shared = c.varint()?;
553            let len = c.len()?;
554            if c.take(len)? <= key {
555                lo = mid + 1;
556            } else {
557                hi = mid;
558            }
559        }
560        let block = lo.checked_sub(1)?;
561        let at = u32_at(ids, block.checked_mul(4)?)? as usize;
562        let mut c = Cur::new(entries, at);
563        // Scan the block, rebuilding each id only as far as it matches `key`:
564        // `matched` is the length of the common prefix of the previous id
565        // and `key`.
566        let mut matched = 0usize;
567        let first = block.checked_mul(IDS_RESTART)?;
568        for i in first..count.min(first.checked_add(IDS_RESTART)?) {
569            let shared = c.len()?;
570            let len = c.len()?;
571            let suffix = c.take(len)?;
572            if shared > matched {
573                // Shares more with the previous id than that id shared with
574                // `key`: it differs from `key` where the previous one did.
575                continue;
576            }
577            // `shared ≤ matched`: the id agrees with `key` on its first
578            // `shared` bytes. Fewer than `matched` does not prove a miss:
579            // IDS does not require `shared` to be maximal (02 §2.8), so the
580            // suffix may repeat bytes of the previous id and still match.
581            let rest = key.get(shared..)?;
582            let common = rest.iter().zip(suffix).take_while(|(a, b)| a == b).count();
583            if common == rest.len() && common == suffix.len() {
584                return MsgId::new(self.chunk, u32::try_from(i).ok()?);
585            }
586            matched = shared.checked_add(common)?;
587        }
588        None
589    }
590}
591
592/// Walks the section table: bounds, order, duplicates, STRINGS last.
593fn sections(b: &[u8]) -> Result<Found, CatalogError> {
594    let n = usize::from(u16_at(b, header::SECTION_COUNT).ok_or(CatalogError::Truncated)?);
595    let table_end = n
596        .checked_mul(SECTION_ENTRY_LEN)
597        .and_then(|t| t.checked_add(HEADER_LEN))
598        .ok_or(CatalogError::Truncated)?;
599    if table_end > b.len() {
600        return Err(CatalogError::Truncated);
601    }
602    let mut found = Found::default();
603    let mut prev_end = table_end;
604    let mut last = None;
605    for i in 0..n {
606        let at = HEADER_LEN + i * SECTION_ENTRY_LEN;
607        let (Some(kind), Some(off), Some(len)) =
608            (u16_at(b, at), u32_at(b, at + 2), u32_at(b, at + 6))
609        else {
610            return Err(CatalogError::Truncated);
611        };
612        let (off, len) = (off as usize, len as usize);
613        let end = off.checked_add(len).ok_or(CatalogError::SectionTable)?;
614        if off < prev_end || end > b.len() || found.strings.is_some() {
615            return Err(CatalogError::SectionTable);
616        }
617        prev_end = end;
618        last = Some(end);
619        if let Some(slot) = found.slot(kind) {
620            if slot.is_some() {
621                return Err(CatalogError::SectionTable);
622            }
623            *slot = Some(Span { off, len });
624        }
625    }
626    if found.strings.is_some() && last != Some(b.len()) {
627        return Err(CatalogError::SectionTable);
628    }
629    Ok(found)
630}
631
632/// INDEX: size, bounds, and strictly increasing MESSAGES offsets.
633fn check_index(
634    index: &[u8],
635    count: usize,
636    messages_len: usize,
637    pool_len: usize,
638) -> Result<(), CatalogError> {
639    if Some(index.len()) != count.checked_mul(4) {
640        return Err(CatalogError::Index);
641    }
642    let mut prev: Option<u32> = None;
643    for i in 0..count {
644        let e = plane_entry(index, count, i).ok_or(CatalogError::Index)?;
645        let off = e & kind::OFFSET_MASK;
646        match e >> kind::SHIFT {
647            kind::SIMPLE => {
648                if off as usize >= pool_len {
649                    return Err(CatalogError::Index);
650                }
651            }
652            kind::PATTERN | kind::SELECT => {
653                if off as usize >= messages_len || prev.is_some_and(|p| off <= p) {
654                    return Err(CatalogError::Index);
655                }
656                prev = Some(off);
657            }
658            _ => {}
659        }
660    }
661    Ok(())
662}
663
664/// NAMES: entries back to back, every `str32` inside the pool.
665fn check_names(names: &[u8], pool_len: usize) -> Option<()> {
666    let mut c = Cur::new(names, 0);
667    while !c.at_end() {
668        let n = c.len()?.checked_add(c.len()?)?;
669        if n > c.remaining() / 4 {
670            return None;
671        }
672        for _ in 0..n {
673            if c.u32()? as usize >= pool_len {
674                return None;
675            }
676        }
677    }
678    Some(())
679}
680
681/// FUNCS: `str32`s inside the pool.
682fn check_funcs(funcs: &[u8], pool_len: usize) -> Option<()> {
683    if !funcs.len().is_multiple_of(4) {
684        return None;
685    }
686    let mut c = Cur::new(funcs, 0);
687    while !c.at_end() {
688        if c.u32()? as usize >= pool_len {
689            return None;
690        }
691    }
692    Some(())
693}
694
695/// FALLBACK: the locale table, then entries strictly increasing by message.
696fn check_fallback(buf: &[u8], sec: Span, count: u32, pool_len: usize) -> Option<Fallback> {
697    let mut c = Cur::new(sec.of(buf), 0);
698    let n_locales = c.len()?;
699    if n_locales > MAX_FALLBACK_LOCALES || n_locales > c.remaining() / 4 {
700        return None;
701    }
702    let locales = Span {
703        off: sec.off.checked_add(c.pos())?,
704        len: n_locales * 4,
705    };
706    for _ in 0..n_locales {
707        if c.u32()? as usize >= pool_len {
708            return None;
709        }
710    }
711    let entries = Span {
712        off: sec.off.checked_add(c.pos())?,
713        len: c.remaining(),
714    };
715    if !entries.len.is_multiple_of(4) {
716        return None;
717    }
718    let mut prev: Option<u32> = None;
719    while !c.at_end() {
720        let e = c.u32()?;
721        let (msg, loc) = (e & 0x00ff_ffff, (e >> 24) as usize);
722        if msg >= count || loc >= n_locales || prev.is_some_and(|p| msg <= p) {
723            return None;
724        }
725        prev = Some(msg);
726    }
727    Some(Fallback { locales, entries })
728}
729
730/// LOCALE: the container, keys strictly increasing; the plural entries
731/// walked for structure. Returns the plural entries' spans.
732fn check_locale(b: &[u8], s: Span) -> Option<[Option<Span>; 2]> {
733    let mut c = Cur::new(s.of(b), 0);
734    let n = c.varint()?;
735    let mut plural = [None, None];
736    let mut prev: Option<u32> = None;
737    for _ in 0..n {
738        let key = c.varint()?;
739        if prev.is_some_and(|p| key <= p) {
740            return None;
741        }
742        prev = Some(key);
743        let len = c.len()?;
744        let at = c.pos();
745        let payload = c.take(len)?;
746        let span = Span {
747            off: s.off.checked_add(at)?,
748            len,
749        };
750        match key {
751            locale_key::PLURAL_CARDINAL | locale_key::PLURAL_ORDINAL => {
752                if !plural::valid(payload) {
753                    return None;
754                }
755                if let Some(slot) = plural.get_mut(key as usize - 1) {
756                    *slot = Some(span);
757                }
758            }
759            _ => {}
760        }
761    }
762    c.at_end().then_some(plural)
763}
764
765/// IDS: restart table, then `count` front-coded ids with a restart every
766/// [`IDS_RESTART`].
767fn check_ids(ids: &[u8], count: usize) -> Option<()> {
768    let blocks = count.div_ceil(IDS_RESTART);
769    let table_len = blocks.checked_mul(4)?;
770    let table = ids.get(..table_len)?;
771    let mut c = Cur::new(ids.get(table_len..)?, 0);
772    let mut prev_len = 0usize;
773    for i in 0..count {
774        let at = c.pos();
775        let shared = c.len()?;
776        let len = c.len()?;
777        if i % IDS_RESTART == 0 {
778            if shared != 0 || u32_at(table, (i / IDS_RESTART) * 4)? as usize != at {
779                return None;
780            }
781        } else if shared > prev_len {
782            return None;
783        }
784        c.skip(len)?;
785        prev_len = shared.checked_add(len)?;
786    }
787    c.at_end().then_some(())
788}