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}