Skip to main content

cratefield_module_telemetry/
payload.rs

1//! The batch payload and its closed grammar (issue #413). Every field is
2//! validated against a fixed vocabulary — an enum, a bounded count, a
3//! duration bucket, a fixed-width hex id or a numeric version triple — and
4//! a value outside any of them is a rejection, never a truncation or a
5//! coercion. There is no string anywhere a sentence could fit, which is
6//! what makes the consent notice short enough to be true: the collector
7//! can list every value it will ever hold, because the client sent it.
8
9use serde::{Deserialize, Serialize, de};
10use serde_json::Value;
11use std::fmt::Display;
12
13use crate::consent::{ROTATION_DAYS, install_id_is_valid};
14
15/// The only payload schema this crate parses. Any other value is its own
16/// rejection (and its own problem slug at the route): a schema bump is a
17/// new contract, not a hint.
18pub const SCHEMA: u32 = 1;
19
20/// The most modules one batch may report (issue #413). A ceiling on the
21/// payload, so a client cannot grow it into a list of whatever it wants.
22pub const MAX_MODULES: usize = 32;
23
24/// The most event records one batch may carry (issue #413). The ceiling is
25/// part of the closed grammar: "at most this many" is what bounds what a
26/// single request can do, on a route with no other way to bound it.
27pub const MAX_EVENTS_PER_BATCH: usize = 64;
28
29/// The most runs one event record may claim. Bounded so a bad client
30/// cannot claim a billion runs in one small, valid-shaped body.
31pub const MAX_EVENT_COUNT: u32 = 100_000;
32
33/// Defines a closed enum with a stable wire name, an `ALL` slice and an
34/// exhaustive `as_str`, in the shape of `DataKind` in core (issue #413).
35/// The wire names are spelled out per variant rather than derived: values
36/// like `100ms-1s` cannot come from a Rust identifier, and a renamed wire
37/// value is a new contract.
38macro_rules! closed_enum {
39    ($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),+ $(,)? }) => {
40        $(#[$meta])*
41        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42        pub enum $name {
43            $(
44                #[serde(rename = $wire)]
45                $variant,
46            )+
47        }
48
49        impl $name {
50            /// Every value, in declaration order.
51            pub const ALL: &'static [Self] = &[$(Self::$variant),+];
52
53            /// The stable wire name.
54            #[must_use]
55            pub const fn as_str(self) -> &'static str {
56                match self {
57                    $(Self::$variant => $wire),+
58                }
59            }
60
61            /// Never called; exists so that adding a variant without
62            /// adding it to this match — and so to `ALL` — is a compile
63            /// error here rather than a hole in the published vocabulary.
64            #[expect(dead_code, reason = "its only job is to be exhaustive")]
65            fn every_value_is_in_all(value: Self) {
66                match value {
67                    $(Self::$variant => {}),+
68                }
69            }
70        }
71
72        impl std::fmt::Display for $name {
73            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74                f.write_str(self.as_str())
75            }
76        }
77    };
78}
79
80closed_enum! {
81    /// What kind of thing is reporting.
82    ClientKind {
83        Cli => "cli",
84        Desktop => "desktop",
85        Agent => "agent",
86        Server => "server",
87        Browser => "browser",
88        Other => "other",
89    }
90}
91
92closed_enum! {
93    /// The operating system family the client runs on.
94    Platform {
95        Linux => "linux",
96        Macos => "macos",
97        Windows => "windows",
98        Other => "other",
99    }
100}
101
102closed_enum! {
103    /// The instruction set the client runs as. `x86-64` with a hyphen:
104    /// the wire name follows the architecture's own spelling.
105    Arch {
106        X86_64 => "x86-64",
107        Aarch64 => "aarch64",
108        Other => "other",
109    }
110}
111
112closed_enum! {
113    /// How the run ended.
114    Outcome {
115        Ok => "ok",
116        Error => "error",
117        Cancelled => "cancelled",
118    }
119}
120
121closed_enum! {
122    /// Why a run failed — a class, never a message. `None` is the value
123    /// that pairs with the `Ok` and `Cancelled` outcomes; see
124    /// [`Rejection::ErrorMismatch`] for the rule that binds them.
125    ErrorKind {
126        None => "none",
127        Network => "network",
128        Timeout => "timeout",
129        Auth => "auth",
130        Config => "config",
131        Permission => "permission",
132        NotFound => "not-found",
133        Conflict => "conflict",
134        RateLimited => "rate-limited",
135        Unsupported => "unsupported",
136        Internal => "internal",
137        Other => "other",
138    }
139}
140
141closed_enum! {
142    /// How long the run took — a bucket, not a stopwatch. A precise
143    /// duration would be a number a client could be talked into padding
144    /// with meaning; a bucket is only good for the question it answers.
145    DurationBucket {
146        Unknown => "unknown",
147        Under100ms => "under-100ms",
148        S100msTo1s => "100ms-1s",
149        S1To10s => "1s-10s",
150        S10To1m => "10s-1m",
151        S1mTo10m => "1m-10m",
152        Over10m => "over-10m",
153    }
154}
155
156/// A numeric release triple: `MAJOR.MINOR.PATCH`, each part one to six
157/// digits, nothing else (issue #413). Pre-release tags and build metadata
158/// are free text wearing a semver costume — `1.0.0-beta+homebrew` can
159/// carry an opinion about a distribution channel, a flag, an experiment —
160/// so a client that has one rounds down to the release triple before
161/// reporting.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
163pub struct Version {
164    /// The major version.
165    pub major: u32,
166    /// The minor version.
167    pub minor: u32,
168    /// The patch version.
169    pub patch: u32,
170}
171
172impl Version {
173    /// Parses a release triple, or nothing: anything but exactly three
174    /// dot-separated digit parts — a fourth part, a pre-release tag, build
175    /// metadata, an empty part — is no version at all.
176    #[must_use]
177    pub fn parse(raw: &str) -> Option<Self> {
178        let (major, rest) = raw.split_once('.')?;
179        let (minor, rest) = rest.split_once('.')?;
180        // No third dot: a fourth part is metadata or worse, and the
181        // grammar above says a client rounds down rather than report it.
182        if rest.contains('.') {
183            return None;
184        }
185        Some(Self {
186            major: part(major)?,
187            minor: part(minor)?,
188            patch: part(rest)?,
189        })
190    }
191}
192
193/// One part of a [`Version`]: one to six ASCII digits, which fits a
194/// `u32` with room to spare and cannot begin a pre-release tag.
195fn part(raw: &str) -> Option<u32> {
196    if raw.is_empty() || raw.len() > 6 || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
197        return None;
198    }
199    raw.parse().ok()
200}
201
202impl std::fmt::Display for Version {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
205    }
206}
207
208impl Serialize for Version {
209    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
210        serializer.collect_str(self)
211    }
212}
213
214impl<'de> Deserialize<'de> for Version {
215    fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
216        let raw = String::deserialize(deserializer)?;
217        // This path is only exercised on an already-accepted batch's own
218        // bytes (the status round trip); the ingest path rejects through
219        // [`Rejection`], whose rendering never quotes a value.
220        Self::parse(&raw)
221            .ok_or_else(|| de::Error::custom("expected a MAJOR.MINOR.PATCH release triple"))
222    }
223}
224
225/// The client section of a batch: what kind of thing reported, and on
226/// what. Every field is a closed value.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct Client {
230    /// What kind of thing is reporting.
231    pub kind: ClientKind,
232    /// The release triple the client reports (rounded down, per
233    /// [`Version`]).
234    pub version: Version,
235    /// The operating system family.
236    pub platform: Platform,
237    /// The instruction set.
238    pub arch: Arch,
239}
240
241/// One counted event: a named run, how it ended, in which bucket of time,
242/// and how many times. The batch is *counted* — one record aggregates many
243/// runs — which is why there is no timestamp, no duration number and no
244/// message per run.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct EventRecord {
248    /// A name from the venture's declared event vocabulary.
249    pub name: String,
250    /// How the run ended.
251    pub outcome: Outcome,
252    /// The error class; `None` exactly when the outcome is not `Error`.
253    pub error: ErrorKind,
254    /// Which bucket of time the run fell into.
255    pub duration: DurationBucket,
256    /// How many runs this record aggregates, `1..=[MAX_EVENT_COUNT]`.
257    pub count: u32,
258}
259
260/// A whole reporting batch, version [`SCHEMA`]. Built only by
261/// [`Batch::parse`], which enforces the grammar this type's shape can only
262/// suggest: the derives exist for the wire format and the status round
263/// trip, never as an ingest path.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct Batch {
267    /// The schema version; always [`SCHEMA`] after a parse.
268    pub schema: u32,
269    /// The install id: 32 lowercase hex characters.
270    pub install: String,
271    /// What reported.
272    pub client: Client,
273    /// Which of the venture's declared modules the client composes, no
274    /// duplicates.
275    pub modules: Vec<String>,
276    /// One or more counted events.
277    pub events: Vec<EventRecord>,
278}
279
280/// The venture-declared part of the grammar: the event names and composed
281/// module names the collector will count, and the batch ceiling. Both
282/// lists default to **empty**, which means [`Batch::parse`] rejects every
283/// batch naming an event or a module — fail closed, on purpose (issue
284/// #413): a vocabulary the venture forgot is a collector that counts
285/// nothing, not one that counts anything it is sent.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Vocabulary {
288    /// The event names a batch may count.
289    pub events: Vec<String>,
290    /// The composed module names a client may report.
291    pub modules: Vec<String>,
292    /// The batch ceiling; never above [`MAX_EVENTS_PER_BATCH`].
293    pub max_events: usize,
294}
295
296impl Default for Vocabulary {
297    fn default() -> Self {
298        // Empty lists, the schema ceiling: the most closed a grammar can
299        // be while still being one.
300        Self {
301            events: Vec::new(),
302            modules: Vec::new(),
303            max_events: MAX_EVENTS_PER_BATCH,
304        }
305    }
306}
307
308/// Why [`Batch::parse`] refused a batch. The rendering is the contract: a
309/// **fixed sentence** that names the field path and the rule, and never
310/// echoes the offending value. An error body that quoted what the client
311/// sent would be the free-text channel this module exists to close, one
312/// `Debug` print away from a log file.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum Rejection {
315    /// The body is not a JSON object.
316    Body,
317    /// A field the schema does not define, at any level.
318    UnknownField,
319    /// `/schema` is missing or not a number.
320    Schema,
321    /// `/schema` names a version this collector does not parse.
322    SchemaUnsupported,
323    /// `/install` is not 32 lowercase hex characters.
324    Install,
325    /// `/client` is missing or not an object.
326    Client,
327    /// `/client/kind` is not a declared kind.
328    ClientKind,
329    /// `/client/version` is not a release triple.
330    ClientVersion,
331    /// `/client/platform` is not a declared platform.
332    ClientPlatform,
333    /// `/client/arch` is not a declared architecture.
334    ClientArch,
335    /// `/modules` is not a list of declared names.
336    Modules,
337    /// `/modules` names the same module more than once.
338    ModulesDuplicate,
339    /// `/modules` carries more than the schema ceiling.
340    ModulesTooMany,
341    /// `/events` is not a list of records.
342    Events,
343    /// `/events` carries no record.
344    EventsEmpty,
345    /// `/events` carries more than the configured ceiling.
346    EventsTooMany,
347    /// `/events[]/name` is not a declared event name.
348    EventName,
349    /// `/events[]/outcome` is not a declared outcome.
350    EventOutcome,
351    /// `/events[]/error` is not a declared error class.
352    EventError,
353    /// `/events[]/error` contradicts `/events[]/outcome`.
354    ErrorMismatch,
355    /// `/events[]/duration` is not a declared bucket.
356    EventDuration,
357    /// `/events[]/count` is not a whole number in range.
358    EventCount,
359}
360
361impl std::fmt::Display for Rejection {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        let sentence = match self {
364            // Rule text only, in every branch: the message is allowed to
365            // name the grammar (which is published on the notice route)
366            // and never the value that broke it. Sentences deliberately
367            // carry no digits where the refused value was a number, and
368            // never repeat a key or name the client chose.
369            Self::Body => "the payload must be a JSON object",
370            Self::UnknownField => {
371                "the payload carries a field the schema does not define; \
372                 a new field is a new schema version, never an accepted one"
373            }
374            Self::Schema => {
375                "the value at /schema must be present and be the schema version this collector parses"
376            }
377            Self::SchemaUnsupported => {
378                "the value at /schema names a schema version this collector does not parse; \
379                 upgrade the client or stop reporting"
380            }
381            Self::Install => {
382                "the value at /install must be exactly 32 lowercase hexadecimal characters"
383            }
384            Self::Client => "the value at /client must be the client object the schema defines",
385            Self::ClientKind => {
386                "the value at /client/kind must be one of the kinds the notice route publishes"
387            }
388            Self::ClientVersion => {
389                "the value at /client/version must be a numeric release triple — \
390                 MAJOR.MINOR.PATCH, each part one to six digits — with no pre-release \
391                 and no build metadata; a client with one of those rounds down"
392            }
393            Self::ClientPlatform => {
394                "the value at /client/platform must be one of the platforms the notice \
395                 route publishes"
396            }
397            Self::ClientArch => {
398                "the value at /client/arch must be one of the architectures the notice \
399                 route publishes"
400            }
401            Self::Modules => {
402                "the value at /modules must be a list of names from the venture's \
403                 declared module vocabulary"
404            }
405            Self::ModulesDuplicate => {
406                "the value at /modules must not name the same module more than once"
407            }
408            Self::ModulesTooMany => {
409                "the value at /modules must not carry more names than the schema ceiling"
410            }
411            Self::Events => "the value at /events must be a list of event records",
412            Self::EventsEmpty => "the value at /events must carry at least one event record",
413            Self::EventsTooMany => {
414                "the value at /events must not carry more records than the configured batch ceiling"
415            }
416            Self::EventName => {
417                "the value at /events[]/name must be a name from the venture's declared \
418                 event vocabulary"
419            }
420            Self::EventOutcome => {
421                "the value at /events[]/outcome must be one of the outcomes the notice \
422                 route publishes"
423            }
424            Self::EventError => {
425                "the value at /events[]/error must be one of the error classes the \
426                 notice route publishes"
427            }
428            Self::ErrorMismatch => {
429                "the value at /events[]/error must be none when the outcome is ok or \
430                 cancelled, and must never be none when the outcome is error"
431            }
432            Self::EventDuration => {
433                "the value at /events[]/duration must be one of the duration buckets the \
434                 notice route publishes"
435            }
436            Self::EventCount => {
437                "the value at /events[]/count must be a whole number between one and one \
438                 hundred thousand"
439            }
440        };
441        f.write_str(sentence)
442    }
443}
444
445/// Reads one closed value: the JSON field must be a string equal to one
446/// member of `all`. A wrong type and an undeclared name are the same
447/// rejection — the message names the rule, never the value.
448fn enum_of<E>(
449    value: &Value,
450    all: &[E],
451    wire: impl Fn(E) -> &'static str,
452    rejection: Rejection,
453) -> Result<E, Rejection>
454where
455    E: Copy + PartialEq,
456{
457    let raw = value.as_str().ok_or(rejection)?;
458    all.iter()
459        .copied()
460        .find(|candidate| wire(*candidate) == raw)
461        .ok_or(rejection)
462}
463
464impl Batch {
465    /// Parses a batch against the venture's declared vocabulary. Every
466    /// value outside the grammar — an unknown field, an undeclared name, a
467    /// malformed id, a version with a pre-release tag, a count out of
468    /// range, an error class that contradicts the outcome — is a
469    /// [`Rejection`], never a truncation or a coercion (issue #413).
470    ///
471    /// The parse is written by hand over `serde_json::Value` rather than
472    /// delegated to serde's deserializers on purpose: serde's error
473    /// messages quote the offending value, and a quoted value is exactly
474    /// what a rejection body must never carry.
475    ///
476    /// # Errors
477    /// A [`Rejection`] naming the field path and the rule that was broken.
478    pub fn parse(value: &Value, vocabulary: &Vocabulary) -> Result<Self, Rejection> {
479        let object = value.as_object().ok_or(Rejection::Body)?;
480        let mut schema = false;
481        let mut install = None;
482        let mut client = None;
483        let mut modules = None;
484        let mut events = None;
485        for (key, field) in object {
486            match key.as_str() {
487                "schema" => match field.as_u64() {
488                    Some(found) if found == u64::from(SCHEMA) => schema = true,
489                    Some(_) => return Err(Rejection::SchemaUnsupported),
490                    None => return Err(Rejection::Schema),
491                },
492                "install" => match field.as_str() {
493                    Some(raw) if install_id_is_valid(raw) => install = Some(raw.to_owned()),
494                    _ => return Err(Rejection::Install),
495                },
496                "client" => client = Some(parse_client(field)?),
497                "modules" => modules = Some(parse_modules(field, vocabulary)?),
498                "events" => events = Some(parse_events(field, vocabulary)?),
499                _ => return Err(Rejection::UnknownField),
500            }
501        }
502        Ok(Self {
503            schema: if schema {
504                SCHEMA
505            } else {
506                return Err(Rejection::Schema);
507            },
508            install: install.ok_or(Rejection::Install)?,
509            client: client.ok_or(Rejection::Client)?,
510            modules: modules.ok_or(Rejection::Modules)?,
511            events: events.ok_or(Rejection::Events)?,
512        })
513    }
514}
515
516/// Parses `/client`, admitting exactly the four fields of [`Client`].
517fn parse_client(value: &Value) -> Result<Client, Rejection> {
518    let object = value.as_object().ok_or(Rejection::Client)?;
519    let mut kind = None;
520    let mut version = None;
521    let mut platform = None;
522    let mut arch = None;
523    for (key, field) in object {
524        match key.as_str() {
525            "kind" => {
526                kind = Some(enum_of(
527                    field,
528                    ClientKind::ALL,
529                    ClientKind::as_str,
530                    Rejection::ClientKind,
531                )?);
532            }
533            "version" => match field.as_str().and_then(Version::parse) {
534                Some(parsed) => version = Some(parsed),
535                None => return Err(Rejection::ClientVersion),
536            },
537            "platform" => {
538                platform = Some(enum_of(
539                    field,
540                    Platform::ALL,
541                    Platform::as_str,
542                    Rejection::ClientPlatform,
543                )?);
544            }
545            "arch" => {
546                arch = Some(enum_of(
547                    field,
548                    Arch::ALL,
549                    Arch::as_str,
550                    Rejection::ClientArch,
551                )?);
552            }
553            _ => return Err(Rejection::UnknownField),
554        }
555    }
556    Ok(Client {
557        kind: kind.ok_or(Rejection::ClientKind)?,
558        version: version.ok_or(Rejection::ClientVersion)?,
559        platform: platform.ok_or(Rejection::ClientPlatform)?,
560        arch: arch.ok_or(Rejection::ClientArch)?,
561    })
562}
563
564/// Parses `/modules`: at most [`MAX_MODULES`] names, each from the
565/// venture's declared vocabulary, no duplicates.
566fn parse_modules(value: &Value, vocabulary: &Vocabulary) -> Result<Vec<String>, Rejection> {
567    let items = value.as_array().ok_or(Rejection::Modules)?;
568    if items.len() > MAX_MODULES {
569        return Err(Rejection::ModulesTooMany);
570    }
571    let mut modules = Vec::with_capacity(items.len());
572    for item in items {
573        let name = item.as_str().ok_or(Rejection::Modules)?;
574        if !vocabulary.modules.iter().any(|declared| declared == name) {
575            return Err(Rejection::Modules);
576        }
577        if modules.iter().any(|seen| seen == name) {
578            return Err(Rejection::ModulesDuplicate);
579        }
580        modules.push(name.to_owned());
581    }
582    Ok(modules)
583}
584
585/// Parses `/events`: at least one record and at most the configured
586/// ceiling, each record admitting exactly the five fields of
587/// [`EventRecord`], with the cross-field rule that `error` is `None`
588/// exactly when the outcome is not `Error` — redundant on purpose, because
589/// a pairing that is enforced is one a reader never has to second-guess.
590fn parse_events(value: &Value, vocabulary: &Vocabulary) -> Result<Vec<EventRecord>, Rejection> {
591    let items = value.as_array().ok_or(Rejection::Events)?;
592    if items.is_empty() {
593        return Err(Rejection::EventsEmpty);
594    }
595    if items.len() > vocabulary.max_events {
596        return Err(Rejection::EventsTooMany);
597    }
598    let mut events = Vec::with_capacity(items.len());
599    for item in items {
600        let object = item.as_object().ok_or(Rejection::Events)?;
601        let mut name = None;
602        let mut outcome = None;
603        let mut error = None;
604        let mut duration = None;
605        let mut count = None;
606        for (key, field) in object {
607            match key.as_str() {
608                "name" => {
609                    let raw = field.as_str().ok_or(Rejection::EventName)?;
610                    if !vocabulary.events.iter().any(|declared| declared == raw) {
611                        return Err(Rejection::EventName);
612                    }
613                    name = Some(raw.to_owned());
614                }
615                "outcome" => {
616                    outcome = Some(enum_of(
617                        field,
618                        Outcome::ALL,
619                        Outcome::as_str,
620                        Rejection::EventOutcome,
621                    )?);
622                }
623                "error" => {
624                    error = Some(enum_of(
625                        field,
626                        ErrorKind::ALL,
627                        ErrorKind::as_str,
628                        Rejection::EventError,
629                    )?);
630                }
631                "duration" => {
632                    duration = Some(enum_of(
633                        field,
634                        DurationBucket::ALL,
635                        DurationBucket::as_str,
636                        Rejection::EventDuration,
637                    )?);
638                }
639                "count" => match field.as_u64() {
640                    // Both ends of the range are grammar: a claim of zero
641                    // runs is as meaningless as a claim of a billion.
642                    Some(found) if (1..=u64::from(MAX_EVENT_COUNT)).contains(&found) => {
643                        count = Some(u32::try_from(found).unwrap_or_default());
644                    }
645                    _ => return Err(Rejection::EventCount),
646                },
647                _ => return Err(Rejection::UnknownField),
648            }
649        }
650        let outcome = outcome.ok_or(Rejection::EventOutcome)?;
651        let error = error.ok_or(Rejection::EventError)?;
652        let consistent = match outcome {
653            Outcome::Error => !matches!(error, ErrorKind::None),
654            Outcome::Ok | Outcome::Cancelled => matches!(error, ErrorKind::None),
655        };
656        if !consistent {
657            return Err(Rejection::ErrorMismatch);
658        }
659        events.push(EventRecord {
660            name: name.ok_or(Rejection::EventName)?,
661            outcome,
662            error,
663            duration: duration.ok_or(Rejection::EventDuration)?,
664            count: count.ok_or(Rejection::EventCount)?,
665        });
666    }
667    Ok(events)
668}
669
670/// Serializes a batch exactly the way the sender does — pretty JSON, one
671/// serializer, no second formatting. `telemetry status` embeds these
672/// bytes, so what a person reads is byte-identical to what the wire would
673/// carry; there is no description of the data that can drift from it.
674///
675/// # Panics
676/// Never for a parsed batch: every field is an enum's wire name, a hex
677/// install id, a version triple, a declared name or a number, all of which
678/// serialize, so the underlying `Result` cannot be `Err`.
679#[must_use]
680pub fn to_wire(batch: &Batch) -> String {
681    serde_json::to_string_pretty(batch).expect("a closed payload always serializes")
682}
683
684/// One entry of the field inventory [`fields`] publishes: where the field
685/// sits and what it may hold, generated from the grammar itself so the
686/// notice cannot drift from the parser.
687#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
688pub struct FieldDoc {
689    /// The JSON pointer of the field, as the rejection messages name it.
690    pub path: &'static str,
691    /// The rule in words, for the fields that are not plain closed lists.
692    pub rule: String,
693    /// The permitted values, for the closed lists; generated from the
694    /// enums' `ALL` slices so it cannot disagree with them.
695    pub values: Vec<String>,
696}
697
698/// The wire names of one closed enum, in declaration order — the source
699/// for every inventory entry whose values come from a grammar type, so a
700/// value added to the enum appears in the notice without a second edit.
701fn values<E: Display>(all: &[E]) -> Vec<String> {
702    all.iter().map(ToString::to_string).collect()
703}
704
705/// Every payload field with its permitted vocabulary — the raw material of
706/// the first-run notice and of the notice route, so both are generated
707/// from one place and neither can promise less or more than
708/// [`Batch::parse`] enforces (issue #413).
709#[must_use]
710pub fn fields(vocabulary: &Vocabulary) -> Vec<FieldDoc> {
711    vec![
712        FieldDoc {
713            path: "/schema",
714            rule: format!("exactly the value {SCHEMA}; any other schema is rejected, not coerced"),
715            values: Vec::new(),
716        },
717        FieldDoc {
718            path: "/install",
719            rule: format!(
720                "exactly 32 lowercase hex characters: 16 random bytes the client mints, \
721                 never derived from anything about the machine, replaced every \
722                 {ROTATION_DAYS} days"
723            ),
724            values: Vec::new(),
725        },
726        FieldDoc {
727            path: "/client/kind",
728            rule: String::new(),
729            values: values(ClientKind::ALL),
730        },
731        FieldDoc {
732            path: "/client/version",
733            rule: "a numeric release triple, MAJOR.MINOR.PATCH, each part one to six \
734                   digits; no pre-release, no build metadata"
735                .to_owned(),
736            values: Vec::new(),
737        },
738        FieldDoc {
739            path: "/client/platform",
740            rule: String::new(),
741            values: values(Platform::ALL),
742        },
743        FieldDoc {
744            path: "/client/arch",
745            rule: String::new(),
746            values: values(Arch::ALL),
747        },
748        FieldDoc {
749            path: "/modules",
750            rule: format!(
751                "at most {MAX_MODULES} names, no duplicates, each from the venture's \
752                 declared module vocabulary, listed below"
753            ),
754            values: vocabulary.modules.clone(),
755        },
756        FieldDoc {
757            path: "/events",
758            rule: "at least one record and at most the batch ceiling; each record counts \
759                   many runs of one named command"
760                .to_owned(),
761            values: Vec::new(),
762        },
763        FieldDoc {
764            path: "/events[]/name",
765            rule: "a name from the venture's declared event vocabulary, listed below".to_owned(),
766            values: vocabulary.events.clone(),
767        },
768        FieldDoc {
769            path: "/events[]/outcome",
770            rule: String::new(),
771            values: values(Outcome::ALL),
772        },
773        FieldDoc {
774            path: "/events[]/error",
775            rule: "none when the outcome is ok or cancelled, one of the classes below \
776                   when it is error — a class, never a message"
777                .to_owned(),
778            values: values(ErrorKind::ALL),
779        },
780        FieldDoc {
781            path: "/events[]/duration",
782            rule: String::new(),
783            values: values(DurationBucket::ALL),
784        },
785        FieldDoc {
786            path: "/events[]/count",
787            rule: format!("a whole number from one to {MAX_EVENT_COUNT}"),
788            values: Vec::new(),
789        },
790    ]
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    /// The no-echo rule, on the exact case that motivates it: a payload
798    /// carrying an address is refused with a sentence about the rule, not
799    /// a copy of the address. An error body that quoted the client would
800    /// be the free-text channel this module exists to close (issue #413).
801    #[test]
802    fn a_rejection_never_echoes_what_the_client_sent() {
803        let value = serde_json::json!({
804            "schema": 1,
805            "install": "alice@example.com",
806            "client": {"kind": "cli", "version": "0.4.1", "platform": "linux", "arch": "other"},
807            "modules": [],
808            "events": [
809                {"name": "run", "outcome": "ok", "error": "none", "duration": "unknown", "count": 1}
810            ],
811        });
812        let vocabulary = Vocabulary {
813            events: vec!["run".to_owned()],
814            modules: Vec::new(),
815            max_events: MAX_EVENTS_PER_BATCH,
816        };
817        let rejection =
818            Batch::parse(&value, &vocabulary).expect_err("the address is not an install id");
819        assert_eq!(rejection, Rejection::Install);
820        let message = rejection.to_string();
821        assert!(
822            !message.contains("alice@example.com"),
823            "the rejection echoed what the client sent: {message}"
824        );
825        assert!(
826            message.contains("/install"),
827            "the rejection names the field path: {message}"
828        );
829    }
830
831    /// The count ceiling is part of the grammar: one more than it is a
832    /// rejection, not a clamp.
833    #[test]
834    fn a_count_above_the_ceiling_is_rejected() {
835        let value = serde_json::json!({
836            "schema": 1,
837            "install": "7b0a1f2c3d4e5f60718293a4b5c6d7e8",
838            "client": {"kind": "cli", "version": "0.4.1", "platform": "linux", "arch": "other"},
839            "modules": [],
840            "events": [
841                {"name": "run", "outcome": "ok", "error": "none", "duration": "unknown", "count": MAX_EVENT_COUNT + 1}
842            ],
843        });
844        let vocabulary = Vocabulary {
845            events: vec!["run".to_owned()],
846            modules: Vec::new(),
847            max_events: MAX_EVENTS_PER_BATCH,
848        };
849        assert_eq!(
850            Batch::parse(&value, &vocabulary),
851            Err(Rejection::EventCount),
852            "a count above the ceiling is refused"
853        );
854    }
855
856    /// The version grammar, at its edges: a plain triple parses, and every
857    /// costume free text could wear is refused.
858    #[test]
859    fn a_version_is_a_release_triple_and_nothing_else() {
860        assert_eq!(
861            Version::parse("0.4.1"),
862            Some(Version {
863                major: 0,
864                minor: 4,
865                patch: 1
866            })
867        );
868        assert!(
869            Version::parse("123456.123456.123456").is_some(),
870            "six digits per part"
871        );
872        assert_eq!(
873            Version::parse("1234567.0.0"),
874            None,
875            "a part longer than six digits"
876        );
877        assert_eq!(Version::parse("1.0.0-beta"), None, "a pre-release tag");
878        assert_eq!(Version::parse("1.0.0+homebrew"), None, "build metadata");
879        assert_eq!(Version::parse("1.0"), None, "a missing part");
880        assert_eq!(Version::parse("1.0.0.0"), None, "a fourth part");
881        assert_eq!(Version::parse("latest"), None, "a name");
882        assert_eq!(Version::parse("1..0"), None, "an empty part");
883    }
884}