use serde::{Deserialize, Serialize, de};
use serde_json::Value;
use std::fmt::Display;
use crate::consent::{ROTATION_DAYS, install_id_is_valid};
pub const SCHEMA: u32 = 1;
pub const MAX_MODULES: usize = 32;
pub const MAX_EVENTS_PER_BATCH: usize = 64;
pub const MAX_EVENT_COUNT: u32 = 100_000;
macro_rules! closed_enum {
($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),+ $(,)? }) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum $name {
$(
#[serde(rename = $wire)]
$variant,
)+
}
impl $name {
pub const ALL: &'static [Self] = &[$(Self::$variant),+];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $wire),+
}
}
#[expect(dead_code, reason = "its only job is to be exhaustive")]
fn every_value_is_in_all(value: Self) {
match value {
$(Self::$variant => {}),+
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
};
}
closed_enum! {
ClientKind {
Cli => "cli",
Desktop => "desktop",
Agent => "agent",
Server => "server",
Browser => "browser",
Other => "other",
}
}
closed_enum! {
Platform {
Linux => "linux",
Macos => "macos",
Windows => "windows",
Other => "other",
}
}
closed_enum! {
Arch {
X86_64 => "x86-64",
Aarch64 => "aarch64",
Other => "other",
}
}
closed_enum! {
Outcome {
Ok => "ok",
Error => "error",
Cancelled => "cancelled",
}
}
closed_enum! {
ErrorKind {
None => "none",
Network => "network",
Timeout => "timeout",
Auth => "auth",
Config => "config",
Permission => "permission",
NotFound => "not-found",
Conflict => "conflict",
RateLimited => "rate-limited",
Unsupported => "unsupported",
Internal => "internal",
Other => "other",
}
}
closed_enum! {
DurationBucket {
Unknown => "unknown",
Under100ms => "under-100ms",
S100msTo1s => "100ms-1s",
S1To10s => "1s-10s",
S10To1m => "10s-1m",
S1mTo10m => "1m-10m",
Over10m => "over-10m",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
let (major, rest) = raw.split_once('.')?;
let (minor, rest) = rest.split_once('.')?;
if rest.contains('.') {
return None;
}
Some(Self {
major: part(major)?,
minor: part(minor)?,
patch: part(rest)?,
})
}
}
fn part(raw: &str) -> Option<u32> {
if raw.is_empty() || raw.len() > 6 || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
raw.parse().ok()
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl Serialize for Version {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Version {
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::parse(&raw)
.ok_or_else(|| de::Error::custom("expected a MAJOR.MINOR.PATCH release triple"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Client {
pub kind: ClientKind,
pub version: Version,
pub platform: Platform,
pub arch: Arch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EventRecord {
pub name: String,
pub outcome: Outcome,
pub error: ErrorKind,
pub duration: DurationBucket,
pub count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Batch {
pub schema: u32,
pub install: String,
pub client: Client,
pub modules: Vec<String>,
pub events: Vec<EventRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Vocabulary {
pub events: Vec<String>,
pub modules: Vec<String>,
pub max_events: usize,
}
impl Default for Vocabulary {
fn default() -> Self {
Self {
events: Vec::new(),
modules: Vec::new(),
max_events: MAX_EVENTS_PER_BATCH,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rejection {
Body,
UnknownField,
Schema,
SchemaUnsupported,
Install,
Client,
ClientKind,
ClientVersion,
ClientPlatform,
ClientArch,
Modules,
ModulesDuplicate,
ModulesTooMany,
Events,
EventsEmpty,
EventsTooMany,
EventName,
EventOutcome,
EventError,
ErrorMismatch,
EventDuration,
EventCount,
}
impl std::fmt::Display for Rejection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let sentence = match self {
Self::Body => "the payload must be a JSON object",
Self::UnknownField => {
"the payload carries a field the schema does not define; \
a new field is a new schema version, never an accepted one"
}
Self::Schema => {
"the value at /schema must be present and be the schema version this collector parses"
}
Self::SchemaUnsupported => {
"the value at /schema names a schema version this collector does not parse; \
upgrade the client or stop reporting"
}
Self::Install => {
"the value at /install must be exactly 32 lowercase hexadecimal characters"
}
Self::Client => "the value at /client must be the client object the schema defines",
Self::ClientKind => {
"the value at /client/kind must be one of the kinds the notice route publishes"
}
Self::ClientVersion => {
"the value at /client/version must be a numeric release triple — \
MAJOR.MINOR.PATCH, each part one to six digits — with no pre-release \
and no build metadata; a client with one of those rounds down"
}
Self::ClientPlatform => {
"the value at /client/platform must be one of the platforms the notice \
route publishes"
}
Self::ClientArch => {
"the value at /client/arch must be one of the architectures the notice \
route publishes"
}
Self::Modules => {
"the value at /modules must be a list of names from the venture's \
declared module vocabulary"
}
Self::ModulesDuplicate => {
"the value at /modules must not name the same module more than once"
}
Self::ModulesTooMany => {
"the value at /modules must not carry more names than the schema ceiling"
}
Self::Events => "the value at /events must be a list of event records",
Self::EventsEmpty => "the value at /events must carry at least one event record",
Self::EventsTooMany => {
"the value at /events must not carry more records than the configured batch ceiling"
}
Self::EventName => {
"the value at /events[]/name must be a name from the venture's declared \
event vocabulary"
}
Self::EventOutcome => {
"the value at /events[]/outcome must be one of the outcomes the notice \
route publishes"
}
Self::EventError => {
"the value at /events[]/error must be one of the error classes the \
notice route publishes"
}
Self::ErrorMismatch => {
"the value at /events[]/error must be none when the outcome is ok or \
cancelled, and must never be none when the outcome is error"
}
Self::EventDuration => {
"the value at /events[]/duration must be one of the duration buckets the \
notice route publishes"
}
Self::EventCount => {
"the value at /events[]/count must be a whole number between one and one \
hundred thousand"
}
};
f.write_str(sentence)
}
}
fn enum_of<E>(
value: &Value,
all: &[E],
wire: impl Fn(E) -> &'static str,
rejection: Rejection,
) -> Result<E, Rejection>
where
E: Copy + PartialEq,
{
let raw = value.as_str().ok_or(rejection)?;
all.iter()
.copied()
.find(|candidate| wire(*candidate) == raw)
.ok_or(rejection)
}
impl Batch {
pub fn parse(value: &Value, vocabulary: &Vocabulary) -> Result<Self, Rejection> {
let object = value.as_object().ok_or(Rejection::Body)?;
let mut schema = false;
let mut install = None;
let mut client = None;
let mut modules = None;
let mut events = None;
for (key, field) in object {
match key.as_str() {
"schema" => match field.as_u64() {
Some(found) if found == u64::from(SCHEMA) => schema = true,
Some(_) => return Err(Rejection::SchemaUnsupported),
None => return Err(Rejection::Schema),
},
"install" => match field.as_str() {
Some(raw) if install_id_is_valid(raw) => install = Some(raw.to_owned()),
_ => return Err(Rejection::Install),
},
"client" => client = Some(parse_client(field)?),
"modules" => modules = Some(parse_modules(field, vocabulary)?),
"events" => events = Some(parse_events(field, vocabulary)?),
_ => return Err(Rejection::UnknownField),
}
}
Ok(Self {
schema: if schema {
SCHEMA
} else {
return Err(Rejection::Schema);
},
install: install.ok_or(Rejection::Install)?,
client: client.ok_or(Rejection::Client)?,
modules: modules.ok_or(Rejection::Modules)?,
events: events.ok_or(Rejection::Events)?,
})
}
}
fn parse_client(value: &Value) -> Result<Client, Rejection> {
let object = value.as_object().ok_or(Rejection::Client)?;
let mut kind = None;
let mut version = None;
let mut platform = None;
let mut arch = None;
for (key, field) in object {
match key.as_str() {
"kind" => {
kind = Some(enum_of(
field,
ClientKind::ALL,
ClientKind::as_str,
Rejection::ClientKind,
)?);
}
"version" => match field.as_str().and_then(Version::parse) {
Some(parsed) => version = Some(parsed),
None => return Err(Rejection::ClientVersion),
},
"platform" => {
platform = Some(enum_of(
field,
Platform::ALL,
Platform::as_str,
Rejection::ClientPlatform,
)?);
}
"arch" => {
arch = Some(enum_of(
field,
Arch::ALL,
Arch::as_str,
Rejection::ClientArch,
)?);
}
_ => return Err(Rejection::UnknownField),
}
}
Ok(Client {
kind: kind.ok_or(Rejection::ClientKind)?,
version: version.ok_or(Rejection::ClientVersion)?,
platform: platform.ok_or(Rejection::ClientPlatform)?,
arch: arch.ok_or(Rejection::ClientArch)?,
})
}
fn parse_modules(value: &Value, vocabulary: &Vocabulary) -> Result<Vec<String>, Rejection> {
let items = value.as_array().ok_or(Rejection::Modules)?;
if items.len() > MAX_MODULES {
return Err(Rejection::ModulesTooMany);
}
let mut modules = Vec::with_capacity(items.len());
for item in items {
let name = item.as_str().ok_or(Rejection::Modules)?;
if !vocabulary.modules.iter().any(|declared| declared == name) {
return Err(Rejection::Modules);
}
if modules.iter().any(|seen| seen == name) {
return Err(Rejection::ModulesDuplicate);
}
modules.push(name.to_owned());
}
Ok(modules)
}
fn parse_events(value: &Value, vocabulary: &Vocabulary) -> Result<Vec<EventRecord>, Rejection> {
let items = value.as_array().ok_or(Rejection::Events)?;
if items.is_empty() {
return Err(Rejection::EventsEmpty);
}
if items.len() > vocabulary.max_events {
return Err(Rejection::EventsTooMany);
}
let mut events = Vec::with_capacity(items.len());
for item in items {
let object = item.as_object().ok_or(Rejection::Events)?;
let mut name = None;
let mut outcome = None;
let mut error = None;
let mut duration = None;
let mut count = None;
for (key, field) in object {
match key.as_str() {
"name" => {
let raw = field.as_str().ok_or(Rejection::EventName)?;
if !vocabulary.events.iter().any(|declared| declared == raw) {
return Err(Rejection::EventName);
}
name = Some(raw.to_owned());
}
"outcome" => {
outcome = Some(enum_of(
field,
Outcome::ALL,
Outcome::as_str,
Rejection::EventOutcome,
)?);
}
"error" => {
error = Some(enum_of(
field,
ErrorKind::ALL,
ErrorKind::as_str,
Rejection::EventError,
)?);
}
"duration" => {
duration = Some(enum_of(
field,
DurationBucket::ALL,
DurationBucket::as_str,
Rejection::EventDuration,
)?);
}
"count" => match field.as_u64() {
Some(found) if (1..=u64::from(MAX_EVENT_COUNT)).contains(&found) => {
count = Some(u32::try_from(found).unwrap_or_default());
}
_ => return Err(Rejection::EventCount),
},
_ => return Err(Rejection::UnknownField),
}
}
let outcome = outcome.ok_or(Rejection::EventOutcome)?;
let error = error.ok_or(Rejection::EventError)?;
let consistent = match outcome {
Outcome::Error => !matches!(error, ErrorKind::None),
Outcome::Ok | Outcome::Cancelled => matches!(error, ErrorKind::None),
};
if !consistent {
return Err(Rejection::ErrorMismatch);
}
events.push(EventRecord {
name: name.ok_or(Rejection::EventName)?,
outcome,
error,
duration: duration.ok_or(Rejection::EventDuration)?,
count: count.ok_or(Rejection::EventCount)?,
});
}
Ok(events)
}
#[must_use]
pub fn to_wire(batch: &Batch) -> String {
serde_json::to_string_pretty(batch).expect("a closed payload always serializes")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FieldDoc {
pub path: &'static str,
pub rule: String,
pub values: Vec<String>,
}
fn values<E: Display>(all: &[E]) -> Vec<String> {
all.iter().map(ToString::to_string).collect()
}
#[must_use]
pub fn fields(vocabulary: &Vocabulary) -> Vec<FieldDoc> {
vec![
FieldDoc {
path: "/schema",
rule: format!("exactly the value {SCHEMA}; any other schema is rejected, not coerced"),
values: Vec::new(),
},
FieldDoc {
path: "/install",
rule: format!(
"exactly 32 lowercase hex characters: 16 random bytes the client mints, \
never derived from anything about the machine, replaced every \
{ROTATION_DAYS} days"
),
values: Vec::new(),
},
FieldDoc {
path: "/client/kind",
rule: String::new(),
values: values(ClientKind::ALL),
},
FieldDoc {
path: "/client/version",
rule: "a numeric release triple, MAJOR.MINOR.PATCH, each part one to six \
digits; no pre-release, no build metadata"
.to_owned(),
values: Vec::new(),
},
FieldDoc {
path: "/client/platform",
rule: String::new(),
values: values(Platform::ALL),
},
FieldDoc {
path: "/client/arch",
rule: String::new(),
values: values(Arch::ALL),
},
FieldDoc {
path: "/modules",
rule: format!(
"at most {MAX_MODULES} names, no duplicates, each from the venture's \
declared module vocabulary, listed below"
),
values: vocabulary.modules.clone(),
},
FieldDoc {
path: "/events",
rule: "at least one record and at most the batch ceiling; each record counts \
many runs of one named command"
.to_owned(),
values: Vec::new(),
},
FieldDoc {
path: "/events[]/name",
rule: "a name from the venture's declared event vocabulary, listed below".to_owned(),
values: vocabulary.events.clone(),
},
FieldDoc {
path: "/events[]/outcome",
rule: String::new(),
values: values(Outcome::ALL),
},
FieldDoc {
path: "/events[]/error",
rule: "none when the outcome is ok or cancelled, one of the classes below \
when it is error — a class, never a message"
.to_owned(),
values: values(ErrorKind::ALL),
},
FieldDoc {
path: "/events[]/duration",
rule: String::new(),
values: values(DurationBucket::ALL),
},
FieldDoc {
path: "/events[]/count",
rule: format!("a whole number from one to {MAX_EVENT_COUNT}"),
values: Vec::new(),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rejection_never_echoes_what_the_client_sent() {
let value = serde_json::json!({
"schema": 1,
"install": "alice@example.com",
"client": {"kind": "cli", "version": "0.4.1", "platform": "linux", "arch": "other"},
"modules": [],
"events": [
{"name": "run", "outcome": "ok", "error": "none", "duration": "unknown", "count": 1}
],
});
let vocabulary = Vocabulary {
events: vec!["run".to_owned()],
modules: Vec::new(),
max_events: MAX_EVENTS_PER_BATCH,
};
let rejection =
Batch::parse(&value, &vocabulary).expect_err("the address is not an install id");
assert_eq!(rejection, Rejection::Install);
let message = rejection.to_string();
assert!(
!message.contains("alice@example.com"),
"the rejection echoed what the client sent: {message}"
);
assert!(
message.contains("/install"),
"the rejection names the field path: {message}"
);
}
#[test]
fn a_count_above_the_ceiling_is_rejected() {
let value = serde_json::json!({
"schema": 1,
"install": "7b0a1f2c3d4e5f60718293a4b5c6d7e8",
"client": {"kind": "cli", "version": "0.4.1", "platform": "linux", "arch": "other"},
"modules": [],
"events": [
{"name": "run", "outcome": "ok", "error": "none", "duration": "unknown", "count": MAX_EVENT_COUNT + 1}
],
});
let vocabulary = Vocabulary {
events: vec!["run".to_owned()],
modules: Vec::new(),
max_events: MAX_EVENTS_PER_BATCH,
};
assert_eq!(
Batch::parse(&value, &vocabulary),
Err(Rejection::EventCount),
"a count above the ceiling is refused"
);
}
#[test]
fn a_version_is_a_release_triple_and_nothing_else() {
assert_eq!(
Version::parse("0.4.1"),
Some(Version {
major: 0,
minor: 4,
patch: 1
})
);
assert!(
Version::parse("123456.123456.123456").is_some(),
"six digits per part"
);
assert_eq!(
Version::parse("1234567.0.0"),
None,
"a part longer than six digits"
);
assert_eq!(Version::parse("1.0.0-beta"), None, "a pre-release tag");
assert_eq!(Version::parse("1.0.0+homebrew"), None, "build metadata");
assert_eq!(Version::parse("1.0"), None, "a missing part");
assert_eq!(Version::parse("1.0.0.0"), None, "a fourth part");
assert_eq!(Version::parse("latest"), None, "a name");
assert_eq!(Version::parse("1..0"), None, "an empty part");
}
}