Skip to main content

honk_me/
validate.rs

1use std::collections::BTreeMap;
2
3use reqwest::Url;
4use serde::Serialize;
5
6use crate::error::{Error, FieldError, Result};
7use crate::message::{
8    Action, Category, Defaults, EventType, Message, MetadataValue, Priority, Severity,
9};
10
11/// The largest JSON body Honk accepts: 16 KiB.
12pub const MAX_BODY_BYTES: usize = 16 << 10;
13/// The longest message text, in bytes of UTF-8.
14pub const MAX_MESSAGE_BYTES: usize = 8192;
15/// The longest title, in characters.
16pub const MAX_TITLE: usize = 160;
17/// The longest source, in characters.
18pub const MAX_SOURCE: usize = 64;
19/// The longest environment, in characters.
20pub const MAX_ENVIRONMENT: usize = 32;
21/// The longest channel, in characters.
22pub const MAX_CHANNEL: usize = 64;
23/// The longest group key, in characters.
24pub const MAX_GROUP_KEY: usize = 128;
25/// The longest `url`, `image_url` or action URL, in bytes.
26pub const MAX_URL_BYTES: usize = 2048;
27/// The most actions (buttons) on a message.
28pub const MAX_ACTIONS: usize = 3;
29/// The longest action title, in characters.
30pub const MAX_ACTION_TITLE: usize = 40;
31/// The most metadata keys.
32pub const MAX_METADATA_KEYS: usize = 16;
33/// The longest metadata string value, in characters.
34pub const MAX_METADATA_STRING: usize = 512;
35/// The shortest push lifetime, in seconds.
36pub const MIN_TTL_SECONDS: u32 = 60;
37/// The longest push lifetime, in seconds.
38pub const MAX_TTL_SECONDS: u32 = 86_400;
39const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
40
41/// The exact JSON of `contracts/openapi.yaml` `MessageRequest`.
42#[derive(Serialize)]
43struct Wire<'a> {
44    #[serde(skip_serializing_if = "Option::is_none")]
45    title: Option<&'a str>,
46    message: &'a str,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    severity: Option<Severity>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    priority: Option<Priority>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    category: Option<Category>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    source: Option<&'a str>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    environment: Option<&'a str>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    channel: Option<&'a str>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    group_key: Option<&'a str>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    event_type: Option<EventType>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    occurred_at: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    url: Option<&'a str>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    image_url: Option<&'a str>,
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    actions: &'a Vec<Action>,
71    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
72    metadata: &'a BTreeMap<String, MetadataValue>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    ttl_seconds: Option<u32>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    source_sequence: Option<u64>,
77}
78
79/// An empty string counts as unset (like `omitempty` in the Go SDK).
80fn set(v: &Option<String>) -> Option<&str> {
81    v.as_deref().filter(|s| !s.is_empty())
82}
83
84fn with_default<'a>(v: &'a Option<String>, d: &'a Option<String>) -> Option<&'a str> {
85    set(v).or_else(|| set(d))
86}
87
88/// Validates `message` (unless `validate` is false) with `defaults` applied and returns the
89/// exact JSON body that [`Honk::send`](crate::Honk::send) posts. Useful for logging, dry runs
90/// and tests.
91///
92/// ```
93/// use honk_me::{encode_message, Defaults, Message, Severity};
94///
95/// let body = encode_message(&Message::new("Backup finished").severity(Severity::BEEP), &Defaults::new().source("nightly"), true)?;
96/// assert_eq!(body, r#"{"message":"Backup finished","severity":"success","source":"nightly"}"#);
97/// # Ok::<(), honk_me::Error>(())
98/// ```
99pub fn encode_message(message: &Message, defaults: &Defaults, validate: bool) -> Result<String> {
100    let wire = Wire {
101        title: set(&message.title),
102        message: &message.message,
103        severity: message.severity,
104        priority: message.priority,
105        category: message.category,
106        source: with_default(&message.source, &defaults.source),
107        environment: with_default(&message.environment, &defaults.environment),
108        channel: with_default(&message.channel, &defaults.channel),
109        group_key: set(&message.group_key),
110        event_type: message.event_type,
111        occurred_at: message.occurred_at.map(crate::time::format_rfc3339),
112        url: set(&message.url),
113        image_url: set(&message.image_url),
114        actions: &message.actions,
115        metadata: &message.metadata,
116        ttl_seconds: message.ttl_seconds,
117        source_sequence: message.source_sequence,
118    };
119    if validate {
120        let errors = check(&wire);
121        if !errors.is_empty() {
122            return Err(Error::local_validation(errors));
123        }
124    } else if message.message.is_empty() {
125        return Err(Error::local_validation(vec![FieldError::new(
126            "message",
127            "required",
128            "message is required",
129        )]));
130    }
131    let body = serde_json::to_string(&wire).map_err(|e| {
132        Error::local_validation(vec![FieldError::new(
133            "body",
134            "invalid_format",
135            e.to_string(),
136        )])
137    })?;
138    if body.len() > MAX_BODY_BYTES {
139        return Err(Error::local_validation(vec![FieldError::new(
140            "body",
141            "too_long",
142            format!(
143                "the JSON body is {} bytes; Honk accepts at most 16 KiB",
144                body.len()
145            ),
146        )]));
147    }
148    Ok(body)
149}
150
151/// Unicode control characters and the line/paragraph separators, like the server.
152fn has_control(s: &str, allow_breaks: bool) -> bool {
153    s.chars().any(|c| {
154        if allow_breaks && matches!(c, '\n' | '\t' | '\r') {
155            return false;
156        }
157        c.is_control() || c == '\u{2028}' || c == '\u{2029}'
158    })
159}
160
161fn short_text(errors: &mut Vec<FieldError>, name: &str, value: Option<&str>, max: usize) {
162    let Some(v) = value else { return };
163    let s = v.trim();
164    if s.is_empty() {
165        errors.push(FieldError::new(name, "too_short", "must not be empty"));
166    } else if s.chars().count() > max {
167        errors.push(FieldError::new(
168            name,
169            "too_long",
170            format!("must be at most {max} characters"),
171        ));
172    } else if has_control(s, false) {
173        errors.push(FieldError::new(
174            name,
175            "invalid_format",
176            "must not contain control characters or line breaks",
177        ));
178    }
179}
180
181/// The server's syntactic check: https, a host, no credentials, at most 2048 bytes; image URLs
182/// additionally have no fragment.
183pub(crate) fn valid_url(raw: &str, image: bool) -> bool {
184    let s = raw.trim();
185    if s.is_empty() || s.len() > MAX_URL_BYTES || has_control(s, false) || s.contains([' ', '\\']) {
186        return false;
187    }
188    let Ok(u) = Url::parse(s) else { return false };
189    if u.scheme() != "https"
190        || u.host_str().is_none_or(str::is_empty)
191        || !u.username().is_empty()
192        || u.password().is_some()
193    {
194        return false;
195    }
196    !(image && s.contains('#'))
197}
198
199/// At most 3 (beyond that only `actions` is reported, like the server); each title 1–40
200/// characters on one line, each URL at most 2048 bytes with an allowed scheme.
201fn check_actions(f: &mut Vec<FieldError>, actions: &[Action]) {
202    if actions.len() > MAX_ACTIONS {
203        f.push(FieldError::new(
204            "actions",
205            "too_long",
206            format!("at most {MAX_ACTIONS} actions"),
207        ));
208        return;
209    }
210    for (i, action) in actions.iter().enumerate() {
211        let field = format!("actions[{i}].title");
212        let title = action.title.trim();
213        if title.is_empty() {
214            f.push(FieldError::new(field, "required", "must not be blank"));
215        } else if title.chars().count() > MAX_ACTION_TITLE {
216            f.push(FieldError::new(
217                field,
218                "too_long",
219                format!("must be at most {MAX_ACTION_TITLE} characters"),
220            ));
221        } else if has_control(title, false) {
222            f.push(FieldError::new(
223                field,
224                "invalid_format",
225                "must be one line without control characters",
226            ));
227        }
228        let field = format!("actions[{i}].url");
229        let url = action.url.trim();
230        if url.is_empty() {
231            f.push(FieldError::new(field, "required", "must not be blank"));
232        } else if url.len() > MAX_URL_BYTES {
233            f.push(FieldError::new(
234                field,
235                "too_long",
236                format!("must be at most {MAX_URL_BYTES} bytes"),
237            ));
238        } else if !valid_action_url(url) {
239            f.push(FieldError::new(
240                field,
241                "invalid_format",
242                "must be an https://, mailto:, tel: or sms: URL without spaces",
243            ));
244        }
245    }
246}
247
248/// The server's check of an action URL, schemes in any case: `https://` as `url`; `mailto:`
249/// with one plain address (dotted domain) and an optional `?subject=…&body=…`; `tel:` or
250/// `tel://` with a number; `sms:` with a number and an optional `?body=…`. No whitespace or
251/// control characters, at most 2048 bytes.
252fn valid_action_url(raw: &str) -> bool {
253    let s = raw.trim();
254    if s.is_empty()
255        || s.len() > MAX_URL_BYTES
256        || has_control(s, false)
257        || s.contains(char::is_whitespace)
258    {
259        return false;
260    }
261    let Some((scheme, rest)) = s.split_once(':') else {
262        return false;
263    };
264    let (target, query) = rest.split_once('?').unwrap_or((rest, ""));
265    match scheme.to_lowercase().as_str() {
266        // `Url` is more forgiving than the server here (`https:host`, `https:///host`,
267        // `https://@host`), so the authority is checked first.
268        "https" => {
269            let authority = rest
270                .strip_prefix("//")
271                .and_then(|r| r.split(['/', '?', '#']).next());
272            authority.is_some_and(|a| !a.is_empty() && !a.contains('@')) && valid_url(s, false)
273        }
274        "mailto" => valid_mail_address(target) && valid_action_query(query, &["subject", "body"]),
275        "tel" => valid_phone_number(rest.strip_prefix("//").unwrap_or(rest)),
276        "sms" => valid_phone_number(target) && valid_action_query(query, &["body"]),
277        _ => false,
278    }
279}
280
281/// An optional leading `+`, then digits and the separators `-` `.` `(` `)`, with at least one
282/// digit.
283fn valid_phone_number(s: &str) -> bool {
284    let rest = s.strip_prefix('+').unwrap_or(s);
285    rest.bytes().any(|b| b.is_ascii_digit())
286        && rest
287            .bytes()
288            .all(|b| b.is_ascii_digit() || matches!(b, b'-' | b'.' | b'(' | b')'))
289}
290
291/// One plain, percent-encoded address with a dotted domain, as Go's net/mail parses it:
292/// dot-atoms (or a `[…]` domain literal), no display name, quotes, commas or spaces.
293fn valid_mail_address(raw: &str) -> bool {
294    let Some(address) = percent_decode(raw, false).and_then(|b| String::from_utf8(b).ok()) else {
295        return false;
296    };
297    if address.is_empty() || address.contains([',', '<', '>', '"', ' ']) {
298        return false;
299    }
300    let Some((local, domain)) = address.split_once('@') else {
301        return false;
302    };
303    let visible = |c: char| ('!'..='~').contains(&c) || !c.is_ascii();
304    let dot_atom = |s: &str| {
305        !s.is_empty()
306            && !s.starts_with('.')
307            && !s.ends_with('.')
308            && !s.contains("..")
309            && s.chars()
310                .all(|c| c == '.' || (visible(c) && !"()<>[]:;@\\,\"".contains(c)))
311    };
312    let literal = |s: &str| {
313        s.len() > 2
314            && s.starts_with('[')
315            && s.ends_with(']')
316            && s[1..s.len() - 1]
317                .chars()
318                .all(|c| visible(c) && !"[]\\".contains(c))
319    };
320    dot_atom(local) && domain.contains('.') && (dot_atom(domain) || literal(domain))
321}
322
323/// The query of a mailto: or sms: action: `&`-separated, valid percent-encoding, no `;`, and
324/// only the allowed keys.
325fn valid_action_query(query: &str, allowed: &[&str]) -> bool {
326    query.split('&').filter(|p| !p.is_empty()).all(|pair| {
327        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
328        !pair.contains(';')
329            && percent_decode(value, true).is_some()
330            && percent_decode(key, true).is_some_and(|k| allowed.iter().any(|a| a.as_bytes() == k))
331    })
332}
333
334/// `%XX` escapes decoded (`None` when one is malformed), `+` as a space in queries.
335fn percent_decode(s: &str, plus_is_space: bool) -> Option<Vec<u8>> {
336    let b = s.as_bytes();
337    let mut out = Vec::with_capacity(b.len());
338    let mut i = 0;
339    while i < b.len() {
340        match b[i] {
341            b'%' => {
342                let hi = char::from(*b.get(i + 1)?).to_digit(16)?;
343                let lo = char::from(*b.get(i + 2)?).to_digit(16)?;
344                out.push(u8::try_from(hi << 4 | lo).ok()?);
345                i += 3;
346                continue;
347            }
348            b'+' if plus_is_space => out.push(b' '),
349            c => out.push(c),
350        }
351        i += 1;
352    }
353    Some(out)
354}
355
356/// 1–128 printable ASCII characters (0x21–0x7E).
357pub(crate) fn valid_idempotency_key(k: &str) -> bool {
358    (1..=128).contains(&k.len()) && k.bytes().all(|b| (0x21..=0x7e).contains(&b))
359}
360
361fn valid_metadata_key(k: &str) -> bool {
362    (1..=64).contains(&k.len())
363        && k.bytes()
364            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
365}
366
367fn check(m: &Wire<'_>) -> Vec<FieldError> {
368    let mut f = Vec::new();
369    let text = m.message;
370    if text.is_empty() {
371        f.push(FieldError::new(
372            "message",
373            "required",
374            "message is required",
375        ));
376    } else if text.len() > MAX_MESSAGE_BYTES {
377        f.push(FieldError::new(
378            "message",
379            "too_long",
380            format!(
381                "must be at most {MAX_MESSAGE_BYTES} bytes of UTF-8 (got {})",
382                text.len()
383            ),
384        ));
385    } else if text.trim().is_empty() {
386        f.push(FieldError::new("message", "too_short", "must not be blank"));
387    } else if has_control(text, true) {
388        f.push(FieldError::new(
389            "message",
390            "invalid_format",
391            "must not contain control characters other than line breaks and tabs",
392        ));
393    }
394    short_text(&mut f, "title", m.title, MAX_TITLE);
395    short_text(&mut f, "source", m.source, MAX_SOURCE);
396    short_text(&mut f, "environment", m.environment, MAX_ENVIRONMENT);
397    short_text(&mut f, "channel", m.channel, MAX_CHANNEL);
398    short_text(&mut f, "group_key", m.group_key, MAX_GROUP_KEY);
399
400    if m.source_sequence.is_some_and(|n| n > MAX_SAFE_INTEGER) {
401        f.push(FieldError::new(
402            "source_sequence",
403            "out_of_range",
404            "must be between 0 and 2^53-1",
405        ));
406    }
407    if m.group_key.is_none() {
408        if m.event_type == Some(EventType::Recovery) {
409            f.push(FieldError::new(
410                "group_key",
411                "requires_group_key",
412                "recovery events require group_key",
413            ));
414        }
415        if m.source_sequence.is_some() {
416            f.push(FieldError::new(
417                "source_sequence",
418                "requires_group_key",
419                "source_sequence requires group_key",
420            ));
421        }
422    }
423    if m.url.is_some_and(|u| !valid_url(u, false)) {
424        f.push(FieldError::new(
425            "url",
426            "invalid_format",
427            "must be an https URL without credentials, at most 2048 bytes",
428        ));
429    }
430    if m.image_url.is_some_and(|u| !valid_url(u, true)) {
431        f.push(FieldError::new(
432            "image_url",
433            "invalid_format",
434            "must be an https URL without credentials or fragment, at most 2048 bytes",
435        ));
436    }
437    check_actions(&mut f, m.actions);
438
439    if m.metadata.len() > MAX_METADATA_KEYS {
440        f.push(FieldError::new(
441            "metadata",
442            "too_long",
443            format!("at most {MAX_METADATA_KEYS} keys"),
444        ));
445    }
446    for (k, v) in m.metadata {
447        let field = format!("metadata.{k}");
448        if !valid_metadata_key(k) {
449            f.push(FieldError::new(
450                field,
451                "invalid_format",
452                "keys must match [A-Za-z0-9_.-]{1,64}",
453            ));
454            continue;
455        }
456        let problem = match v {
457            MetadataValue::String(s)
458                if s.chars().count() > MAX_METADATA_STRING || has_control(s, true) =>
459            {
460                Some(format!(
461                    "strings must be at most {MAX_METADATA_STRING} characters, without control characters"
462                ))
463            }
464            MetadataValue::Float(x) if !x.is_finite() => Some("numbers must be finite".to_owned()),
465            _ => None,
466        };
467        if let Some(msg) = problem {
468            f.push(FieldError::new(field, "invalid_format", msg));
469        }
470    }
471
472    if m.ttl_seconds
473        .is_some_and(|t| !(MIN_TTL_SECONDS..=MAX_TTL_SECONDS).contains(&t))
474    {
475        f.push(FieldError::new(
476            "ttl_seconds",
477            "out_of_range",
478            format!("must be between {MIN_TTL_SECONDS} and {MAX_TTL_SECONDS}"),
479        ));
480    }
481    f
482}