1use serde::{Deserialize, Serialize, de};
10use serde_json::Value;
11use std::fmt::Display;
12
13use crate::consent::{ROTATION_DAYS, install_id_is_valid};
14
15pub const SCHEMA: u32 = 1;
19
20pub const MAX_MODULES: usize = 32;
23
24pub const MAX_EVENTS_PER_BATCH: usize = 64;
28
29pub const MAX_EVENT_COUNT: u32 = 100_000;
32
33macro_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 pub const ALL: &'static [Self] = &[$(Self::$variant),+];
52
53 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self {
57 $(Self::$variant => $wire),+
58 }
59 }
60
61 #[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 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 Platform {
95 Linux => "linux",
96 Macos => "macos",
97 Windows => "windows",
98 Other => "other",
99 }
100}
101
102closed_enum! {
103 Arch {
106 X86_64 => "x86-64",
107 Aarch64 => "aarch64",
108 Other => "other",
109 }
110}
111
112closed_enum! {
113 Outcome {
115 Ok => "ok",
116 Error => "error",
117 Cancelled => "cancelled",
118 }
119}
120
121closed_enum! {
122 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
163pub struct Version {
164 pub major: u32,
166 pub minor: u32,
168 pub patch: u32,
170}
171
172impl Version {
173 #[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 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
193fn 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 Self::parse(&raw)
221 .ok_or_else(|| de::Error::custom("expected a MAJOR.MINOR.PATCH release triple"))
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct Client {
230 pub kind: ClientKind,
232 pub version: Version,
235 pub platform: Platform,
237 pub arch: Arch,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct EventRecord {
248 pub name: String,
250 pub outcome: Outcome,
252 pub error: ErrorKind,
254 pub duration: DurationBucket,
256 pub count: u32,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct Batch {
267 pub schema: u32,
269 pub install: String,
271 pub client: Client,
273 pub modules: Vec<String>,
276 pub events: Vec<EventRecord>,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Vocabulary {
288 pub events: Vec<String>,
290 pub modules: Vec<String>,
292 pub max_events: usize,
294}
295
296impl Default for Vocabulary {
297 fn default() -> Self {
298 Self {
301 events: Vec::new(),
302 modules: Vec::new(),
303 max_events: MAX_EVENTS_PER_BATCH,
304 }
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum Rejection {
315 Body,
317 UnknownField,
319 Schema,
321 SchemaUnsupported,
323 Install,
325 Client,
327 ClientKind,
329 ClientVersion,
331 ClientPlatform,
333 ClientArch,
335 Modules,
337 ModulesDuplicate,
339 ModulesTooMany,
341 Events,
343 EventsEmpty,
345 EventsTooMany,
347 EventName,
349 EventOutcome,
351 EventError,
353 ErrorMismatch,
355 EventDuration,
357 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 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
445fn 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 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
516fn 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
564fn 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
585fn 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
688pub struct FieldDoc {
689 pub path: &'static str,
691 pub rule: String,
693 pub values: Vec<String>,
696}
697
698fn values<E: Display>(all: &[E]) -> Vec<String> {
702 all.iter().map(ToString::to_string).collect()
703}
704
705#[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 #[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 #[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 #[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}