Skip to main content

catalog_reader/
lib.rs

1//! The catalog pack, and the reader that serves it — dependency-free on purpose (C-537).
2//!
3//! The pack is one file compiled by `flux-connectors build` from the canonical per-provider
4//! documents (`catalog/<name>.catalog.json`, C-536): every provider's complete published surface,
5//! concatenated behind an offset index, fronted by a versioned header and a content digest. This
6//! crate embeds that file and answers the four catalogue questions over it —
7//! [`providers`], [`provider`], [`operation`], [`operations_of`] — without touching the network,
8//! walking a filesystem, or parsing a byte of JSON at query time. A host that wants a *newer*
9//! catalogue than it was built with loads one from a path with [`Pack::load`], which refuses a
10//! wrong format version, schema version or digest before serving a single record.
11//!
12//! **Zero non-optional dependencies is the contract, not a habit.** The point of the pack is that
13//! catalogue data stops riding code releases; the point of this crate is that reading it costs a
14//! consumer nothing but the crate itself. The digest is SHA-256 — the one hash spelling this
15//! repository records anywhere — so the check is vendored ([`sha256`]) rather than imported.
16//!
17//! # The format (container version 1)
18//!
19//! ```text
20//! flux-connectors-catalog-pack 1                    ← magic + container format version
21//! digest sha256 <64 lowercase hex>                  ← over every byte after this line
22//! schema <n>                                        ← the documents' schema_version
23//! providers <n>
24//! operations <m>
25//! p <id> <start> <len>                              ← one per provider, ordered by id
26//! o <id> <provider> <service> <start> <len>         ← one per operation, ordered by id
27//! payload <len>
28//! <the canonical documents, concatenated in provider-id order>
29//! ```
30//!
31//! Offsets are decimal byte offsets into the payload. A provider's span is its canonical document,
32//! byte for byte; an operation's span slices that operation's own JSON record out of the owning
33//! document. The whole file is UTF-8 text — documents are JSON — so a record is handed out as
34//! `&str` and a consumer brings whatever JSON parser it already has.
35//!
36//! # Forward compatibility, stated once
37//!
38//! - **A newer container format is refused by name**: the version is the first line, checked
39//!   before anything else is believed.
40//! - **A newer document schema is refused by name**: the header's `schema` line is checked against
41//!   [`SUPPORTED_SCHEMA`] before any record is served, because a record this reader hands out is
42//!   one a consumer will act on.
43//! - **Additive growth does not break this reader**: an unknown header line or an unknown index
44//!   row kind is skipped, so a future pack that *adds* a record family still serves everything a
45//!   version-1 consumer asks for. Anything a reader must not ignore is a format bump.
46//!
47//! # What this crate is not
48//!
49//! Not a document model — records are canonical JSON text, and interpreting them is the
50//! resolver's job (C-538). Not the legacy `catalog` API — `codewandler-connector-catalog` remains
51//! the typed `&'static` surface and re-exports this crate as `catalog::reader`. And not an
52//! authentication boundary: the digest catches corruption and truncation, not an author who can
53//! rewrite both the payload and the digest line above it.
54
55mod sha256;
56
57use std::fmt;
58use std::path::Path;
59use std::sync::OnceLock;
60
61/// The container format version this reader understands. A pack declaring a higher one is
62/// refused by name — see [`Error::UnsupportedFormat`].
63pub const FORMAT_VERSION: u32 = 1;
64
65/// The canonical-document schema version this reader serves. A pack carrying a different one is
66/// refused before any record is served — see [`Error::UnsupportedSchema`].
67pub const SUPPORTED_SCHEMA: u32 = 1;
68
69/// The magic word every pack opens with.
70const MAGIC: &str = "flux-connectors-catalog-pack";
71
72/// The pack this crate was built with: the compiled catalogue of the same repository state.
73static EMBEDDED: &[u8] = include_bytes!("../catalog.pack");
74
75/// Why a byte sequence is not a pack this reader will serve.
76///
77/// Every variant is a refusal *before* the first record: a reader that served half a catalogue
78/// and then noticed would have already handed out records nothing verified.
79#[derive(Debug)]
80#[non_exhaustive]
81pub enum Error {
82    /// The first line does not open with the pack's magic word — this is not a pack at all.
83    NotAPack,
84    /// The pack declares a container format this reader does not implement, named so an operator
85    /// reads "upgrade the reader" rather than "the file is corrupt".
86    UnsupportedFormat {
87        /// The version the file declares.
88        found: u32,
89    },
90    /// The pack's documents carry a schema version this reader does not serve — fail closed, by
91    /// name, exactly as the story's forward-compatibility note requires.
92    UnsupportedSchema {
93        /// The schema version the header declares.
94        found: u32,
95    },
96    /// The stated digest is not the digest of the content. Truncation, corruption or a hand-edit;
97    /// whichever it was, no record is served from bytes that disagree with their own header.
98    DigestMismatch {
99        /// The digest the header states.
100        stated: String,
101        /// The digest the bytes actually have.
102        computed: String,
103    },
104    /// The bytes are not valid UTF-8, which a pack — a text container over JSON — always is.
105    NotText,
106    /// Structurally not a version-1 pack: a missing header line, a malformed row, a span pointing
107    /// outside the payload, a payload shorter than declared. The string names the offender.
108    Malformed(String),
109    /// [`Pack::load`] could not read the file. The message carries the path.
110    Io(String),
111}
112
113impl fmt::Display for Error {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Error::NotAPack => write!(f, "not a {MAGIC} file"),
117            Error::UnsupportedFormat { found } => write!(
118                f,
119                "the pack declares container format {found}, but this reader implements \
120                 {FORMAT_VERSION}; a newer pack needs a newer reader"
121            ),
122            Error::UnsupportedSchema { found } => write!(
123                f,
124                "the pack carries document schema {found}, but this reader serves \
125                 {SUPPORTED_SCHEMA}; refusing rather than handing out records it cannot vouch for"
126            ),
127            Error::DigestMismatch { stated, computed } => write!(
128                f,
129                "the pack's stated digest {stated} is not the content's digest {computed}; the \
130                 file is truncated, corrupted or edited"
131            ),
132            Error::NotText => write!(f, "the pack is not UTF-8 text"),
133            Error::Malformed(what) => write!(f, "malformed pack: {what}"),
134            Error::Io(what) => write!(f, "cannot read the pack: {what}"),
135        }
136    }
137}
138
139impl std::error::Error for Error {}
140
141/// The pack's bytes: borrowed from the binary for the embedded pack, owned for a loaded one.
142enum Bytes {
143    Embedded(&'static [u8]),
144    Owned(Vec<u8>),
145}
146
147impl Bytes {
148    fn as_slice(&self) -> &[u8] {
149        match self {
150            Bytes::Embedded(bytes) => bytes,
151            Bytes::Owned(bytes) => bytes,
152        }
153    }
154}
155
156/// One `p` row: a provider and its document's span in the payload.
157struct ProviderRow {
158    id: String,
159    start: usize,
160    len: usize,
161}
162
163/// One `o` row: an operation, its owner, and its record's span in the payload.
164struct OperationRow {
165    id: String,
166    provider: String,
167    service: String,
168    start: usize,
169    len: usize,
170}
171
172/// A parsed, digest-verified pack.
173///
174/// Constructed by [`Pack::load`], [`Pack::from_bytes`], or once for the whole process by
175/// [`embedded`]. Every span was bounds- and boundary-checked at construction, so the accessors on
176/// [`Provider`] and [`Operation`] cannot fail.
177pub struct Pack {
178    bytes: Bytes,
179    payload_start: usize,
180    schema_version: u32,
181    digest: String,
182    /// Sorted by id — restored on parse, so lookups may binary-search regardless of the file.
183    providers: Vec<ProviderRow>,
184    /// Sorted by id, same rule.
185    operations: Vec<OperationRow>,
186}
187
188impl fmt::Debug for Pack {
189    /// The identity and the shape, never the 9-MB payload: a `{:?}` in a log must not print the
190    /// catalogue at an operator.
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        f.debug_struct("Pack")
193            .field("schema_version", &self.schema_version)
194            .field("digest", &self.digest)
195            .field("providers", &self.providers.len())
196            .field("operations", &self.operations.len())
197            .finish_non_exhaustive()
198    }
199}
200
201/// One provider served from a pack: its id and its canonical document.
202#[derive(Clone, Copy)]
203pub struct Provider<'a> {
204    pack: &'a Pack,
205    row: &'a ProviderRow,
206}
207
208impl fmt::Debug for Provider<'_> {
209    /// The id, never the document — same rule as [`Pack`]'s `Debug`.
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        f.debug_struct("Provider")
212            .field("id", &self.row.id)
213            .finish_non_exhaustive()
214    }
215}
216
217impl<'a> Provider<'a> {
218    /// The provider id, e.g. `zendesk`.
219    pub fn id(&self) -> &'a str {
220        &self.row.id
221    }
222
223    /// The provider's canonical document — the exact bytes of its committed
224    /// `catalog/<id>.catalog.json`, as JSON text.
225    pub fn document(&self) -> &'a str {
226        self.pack.payload_slice(self.row.start, self.row.len)
227    }
228
229    /// Every operation this provider publishes, in id order.
230    pub fn operations(self) -> impl Iterator<Item = Operation<'a>> + 'a {
231        let pack = self.pack;
232        let id = self.row.id.as_str();
233        pack.operations
234            .iter()
235            .filter(move |row| row.provider == id)
236            .map(move |row| Operation { pack, row })
237    }
238}
239
240/// One operation served from a pack: its index facts and its record.
241#[derive(Clone, Copy)]
242pub struct Operation<'a> {
243    pack: &'a Pack,
244    row: &'a OperationRow,
245}
246
247impl fmt::Debug for Operation<'_> {
248    /// The index facts, never the record — same rule as [`Pack`]'s `Debug`.
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        f.debug_struct("Operation")
251            .field("id", &self.row.id)
252            .field("provider", &self.row.provider)
253            .field("service", &self.row.service)
254            .finish_non_exhaustive()
255    }
256}
257
258impl<'a> Operation<'a> {
259    /// The operation id, e.g. `zendesk-ticket-show` — unique across the whole pack.
260    pub fn id(&self) -> &'a str {
261        &self.row.id
262    }
263
264    /// The id of the provider that declares it.
265    pub fn provider(&self) -> &'a str {
266        &self.row.provider
267    }
268
269    /// The service the operation belongs to — `default` for a single-surface provider, spelled
270    /// out rather than elided, exactly as the document spells it.
271    pub fn service(&self) -> &'a str {
272        &self.row.service
273    }
274
275    /// The operation's record: its own JSON object, sliced byte for byte out of the owning
276    /// canonical document.
277    pub fn record(&self) -> &'a str {
278        self.pack.payload_slice(self.row.start, self.row.len)
279    }
280
281    /// The whole canonical document of the operation's provider.
282    pub fn document(&self) -> &'a str {
283        self.pack
284            .provider(&self.row.provider)
285            .expect("an operation's provider row exists; verified at construction")
286            .document()
287    }
288}
289
290impl Pack {
291    /// Read and verify a pack at `path` — the constructor for a host loading a newer catalogue
292    /// than it was built with. Refuses a wrong format version, schema version or digest before
293    /// serving any record.
294    ///
295    /// # Errors
296    ///
297    /// [`Error::Io`] when the file cannot be read; otherwise everything
298    /// [`from_bytes`](Self::from_bytes) refuses.
299    pub fn load(path: impl AsRef<Path>) -> Result<Pack, Error> {
300        let path = path.as_ref();
301        let bytes = std::fs::read(path)
302            .map_err(|error| Error::Io(format!("{}: {error}", path.display())))?;
303        Self::from_bytes(bytes)
304    }
305
306    /// Verify and parse a pack from bytes already in hand.
307    ///
308    /// # Errors
309    ///
310    /// Every [`Error`] variant except [`Error::Io`]; see each variant for what it refuses.
311    pub fn from_bytes(bytes: Vec<u8>) -> Result<Pack, Error> {
312        Self::parse(Bytes::Owned(bytes))
313    }
314
315    /// The document schema version the pack carries.
316    pub fn schema_version(&self) -> u32 {
317        self.schema_version
318    }
319
320    /// The verified content digest, as lowercase hex — the pack's identity, usable as a cache key.
321    pub fn digest(&self) -> &str {
322        &self.digest
323    }
324
325    /// Every provider in the pack, in id order.
326    pub fn providers(&self) -> impl ExactSizeIterator<Item = Provider<'_>> {
327        self.providers
328            .iter()
329            .map(|row| Provider { pack: self, row })
330    }
331
332    /// One provider by id.
333    pub fn provider(&self, id: &str) -> Option<Provider<'_>> {
334        self.providers
335            .binary_search_by(|row| row.id.as_str().cmp(id))
336            .ok()
337            .map(|index| Provider {
338                pack: self,
339                row: &self.providers[index],
340            })
341    }
342
343    /// Every operation in the pack, in id order.
344    pub fn operations(&self) -> impl ExactSizeIterator<Item = Operation<'_>> {
345        self.operations
346            .iter()
347            .map(|row| Operation { pack: self, row })
348    }
349
350    /// One operation by id.
351    pub fn operation(&self, id: &str) -> Option<Operation<'_>> {
352        self.operations
353            .binary_search_by(|row| row.id.as_str().cmp(id))
354            .ok()
355            .map(|index| Operation {
356                pack: self,
357                row: &self.operations[index],
358            })
359    }
360
361    /// Every operation of one provider, in id order. An unknown provider yields nothing, exactly
362    /// as the legacy catalogue's `operations_of` does.
363    pub fn operations_of<'s>(&'s self, provider: &str) -> impl Iterator<Item = Operation<'s>> + 's {
364        // Owned, so the returned iterator borrows the pack alone — a caller may drop the id
365        // string it looked up with while still walking the answer.
366        let provider = provider.to_owned();
367        self.operations
368            .iter()
369            .filter(move |row| row.provider == provider)
370            .map(move |row| Operation { pack: self, row })
371    }
372
373    /// A verified span of the payload, as text.
374    fn payload_slice(&self, start: usize, len: usize) -> &str {
375        let bytes = &self.bytes.as_slice()[self.payload_start + start..][..len];
376        std::str::from_utf8(bytes).expect("every span was boundary-checked at construction")
377    }
378
379    /// Parse and verify, in the order the refusals are promised: format, digest, schema,
380    /// structure. Nothing is served until all four hold.
381    fn parse(bytes: Bytes) -> Result<Pack, Error> {
382        let text = std::str::from_utf8(bytes.as_slice()).map_err(|_| Error::NotText)?;
383
384        let mut offset = 0usize;
385        let mut next_line = |what: &'static str| -> Result<(&str, usize), Error> {
386            let rest = &text[offset..];
387            let end = rest
388                .find('\n')
389                .ok_or_else(|| Error::Malformed(format!("the file ends before its {what} line")))?;
390            let line = &rest[..end];
391            offset += end + 1;
392            Ok((line, offset))
393        };
394
395        // 1. The format line, believed before anything else is.
396        let (magic_line, _) = next_line("magic")?;
397        let mut words = magic_line.split(' ');
398        if words.next() != Some(MAGIC) {
399            return Err(Error::NotAPack);
400        }
401        let found: u32 = words
402            .next()
403            .and_then(|version| version.parse().ok())
404            .ok_or_else(|| Error::Malformed(format!("no format version in `{magic_line}`")))?;
405        if found != FORMAT_VERSION {
406            return Err(Error::UnsupportedFormat { found });
407        }
408
409        // 2. The digest, verified over everything after its own line before any of it is parsed.
410        let (digest_line, digested_from) = next_line("digest")?;
411        let stated = digest_line
412            .strip_prefix("digest sha256 ")
413            .ok_or_else(|| Error::Malformed(format!("not a digest line: `{digest_line}`")))?
414            .to_owned();
415        let computed = sha256::hex_digest(&bytes.as_slice()[digested_from..]);
416        if stated != computed {
417            return Err(Error::DigestMismatch { stated, computed });
418        }
419
420        // 3. The header and index: known keys parsed, unknown lines skipped (additive growth),
421        //    `payload` terminating.
422        let mut schema_version: Option<u32> = None;
423        let mut declared_providers: Option<usize> = None;
424        let mut declared_operations: Option<usize> = None;
425        let mut providers: Vec<ProviderRow> = Vec::new();
426        let mut operations: Vec<OperationRow> = Vec::new();
427        let payload_start;
428        let declared_payload;
429        loop {
430            let (line, after) = next_line("payload")?;
431            let mut fields = line.split(' ');
432            match fields.next() {
433                Some("schema") => {
434                    let found = parse_field(line, fields.next())?;
435                    if found != SUPPORTED_SCHEMA {
436                        return Err(Error::UnsupportedSchema { found });
437                    }
438                    schema_version = Some(found);
439                }
440                Some("providers") => declared_providers = Some(parse_field(line, fields.next())?),
441                Some("operations") => declared_operations = Some(parse_field(line, fields.next())?),
442                Some("p") => {
443                    let id = required(line, fields.next())?.to_owned();
444                    let start = parse_field(line, fields.next())?;
445                    let len = parse_field(line, fields.next())?;
446                    providers.push(ProviderRow { id, start, len });
447                }
448                Some("o") => {
449                    let id = required(line, fields.next())?.to_owned();
450                    let provider = required(line, fields.next())?.to_owned();
451                    let service = required(line, fields.next())?.to_owned();
452                    let start = parse_field(line, fields.next())?;
453                    let len = parse_field(line, fields.next())?;
454                    operations.push(OperationRow {
455                        id,
456                        provider,
457                        service,
458                        start,
459                        len,
460                    });
461                }
462                Some("payload") => {
463                    declared_payload = parse_field(line, fields.next())?;
464                    payload_start = after;
465                    break;
466                }
467                // An unknown line is additive growth within this format version, not corruption:
468                // the digest above already vouched for the bytes.
469                _ => {}
470            }
471        }
472
473        // 4. Structure: everything promised must be present, sized and aligned.
474        if schema_version.is_none() {
475            return Err(Error::Malformed("no schema line".into()));
476        }
477        let payload = &text[payload_start..];
478        if payload.len() != declared_payload {
479            return Err(Error::Malformed(format!(
480                "the payload is {} bytes where the header declares {declared_payload}",
481                payload.len()
482            )));
483        }
484        if declared_providers != Some(providers.len()) {
485            return Err(Error::Malformed(format!(
486                "{} provider rows where the header declares {declared_providers:?}",
487                providers.len()
488            )));
489        }
490        if declared_operations != Some(operations.len()) {
491            return Err(Error::Malformed(format!(
492                "{} operation rows where the header declares {declared_operations:?}",
493                operations.len()
494            )));
495        }
496        for (start, len, what) in providers
497            .iter()
498            .map(|row| (row.start, row.len, row.id.as_str()))
499            .chain(
500                operations
501                    .iter()
502                    .map(|row| (row.start, row.len, row.id.as_str())),
503            )
504        {
505            let span = start
506                .checked_add(len)
507                .and_then(|end| payload.get(start..end));
508            if span.is_none() {
509                return Err(Error::Malformed(format!(
510                    "`{what}`'s span {start}+{len} is not a slice of the {declared_payload}-byte \
511                     payload"
512                )));
513            }
514        }
515        providers.sort_by(|a, b| a.id.cmp(&b.id));
516        operations.sort_by(|a, b| a.id.cmp(&b.id));
517        for row in &operations {
518            if providers
519                .binary_search_by(|held| held.id.cmp(&row.provider))
520                .is_err()
521            {
522                return Err(Error::Malformed(format!(
523                    "operation `{}` names provider `{}`, which has no row",
524                    row.id, row.provider
525                )));
526            }
527        }
528
529        Ok(Pack {
530            bytes,
531            payload_start,
532            schema_version: schema_version.expect("checked above"),
533            digest: stated,
534            providers,
535            operations,
536        })
537    }
538}
539
540/// A required text field of an index row, or the malformed-line refusal naming the row.
541fn required<'a>(line: &str, field: Option<&'a str>) -> Result<&'a str, Error> {
542    field
543        .filter(|value| !value.is_empty())
544        .ok_or_else(|| Error::Malformed(format!("a field is missing in `{line}`")))
545}
546
547/// A required numeric field of a header line or index row.
548fn parse_field<T: std::str::FromStr>(line: &str, field: Option<&str>) -> Result<T, Error> {
549    field
550        .and_then(|value| value.parse().ok())
551        .ok_or_else(|| Error::Malformed(format!("not a number where one is required: `{line}`")))
552}
553
554/// The pack this crate embeds, parsed and digest-verified once per process.
555///
556/// # Panics
557///
558/// If the embedded bytes do not verify — a state no released crate can be in, because the pack is
559/// committed beside the reader and CI holds the pair to a fixed point of a build. Panicking beats
560/// returning a `Result` every caller of a compile-time constant would have to invent a story for.
561pub fn embedded() -> &'static Pack {
562    static PACK: OnceLock<Pack> = OnceLock::new();
563    PACK.get_or_init(|| {
564        Pack::parse(Bytes::Embedded(EMBEDDED))
565            .expect("the embedded catalog.pack verifies; it is committed beside this crate")
566    })
567}
568
569/// Every provider in the embedded pack, in id order.
570pub fn providers() -> impl ExactSizeIterator<Item = Provider<'static>> {
571    embedded().providers()
572}
573
574/// One provider of the embedded pack, by id.
575pub fn provider(id: &str) -> Option<Provider<'static>> {
576    embedded().provider(id)
577}
578
579/// One operation of the embedded pack, by id.
580pub fn operation(id: &str) -> Option<Operation<'static>> {
581    embedded().operation(id)
582}
583
584/// Every operation of one provider in the embedded pack, in id order.
585pub fn operations_of(provider: &str) -> impl Iterator<Item = Operation<'static>> {
586    embedded().operations_of(provider)
587}