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
11pub const MAX_BODY_BYTES: usize = 16 << 10;
13pub const MAX_MESSAGE_BYTES: usize = 8192;
15pub const MAX_TITLE: usize = 160;
17pub const MAX_SOURCE: usize = 64;
19pub const MAX_ENVIRONMENT: usize = 32;
21pub const MAX_CHANNEL: usize = 64;
23pub const MAX_GROUP_KEY: usize = 128;
25pub const MAX_URL_BYTES: usize = 2048;
27pub const MAX_ACTIONS: usize = 3;
29pub const MAX_ACTION_TITLE: usize = 40;
31pub const MAX_METADATA_KEYS: usize = 16;
33pub const MAX_METADATA_STRING: usize = 512;
35pub const MIN_TTL_SECONDS: u32 = 60;
37pub const MAX_TTL_SECONDS: u32 = 86_400;
39const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
40
41#[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
79fn 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
88pub 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
151fn 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
181pub(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
199fn 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
248fn 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 "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
281fn 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
291fn 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
323fn 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
334fn 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
356pub(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}