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