1use std::{error::Error, fmt};
2
3use serde::{Deserialize, Serialize};
4
5const MAX_STABLE_ID_LEN: usize = 128;
6
7macro_rules! stable_id {
8 ($name:ident, $description:literal) => {
9 #[doc = $description]
10 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
11 #[serde(transparent)]
12 pub struct $name(Box<str>);
13
14 impl $name {
15 pub fn parse(value: impl Into<Box<str>>) -> Result<Self, StableIdError> {
21 let value = value.into();
22 validate_stable_id(&value)?;
23 Ok(Self(value))
24 }
25
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29 }
30
31 impl fmt::Display for $name {
32 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33 formatter.write_str(self.as_str())
34 }
35 }
36
37 impl<'de> Deserialize<'de> for $name {
38 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39 where
40 D: serde::Deserializer<'de>,
41 {
42 let value = String::deserialize(deserializer)?;
43 Self::parse(value.into_boxed_str()).map_err(serde::de::Error::custom)
44 }
45 }
46 };
47}
48
49stable_id!(
50 ObservationId,
51 "Stable identifier for one immutable observation."
52);
53stable_id!(
54 SourceId,
55 "Stable identifier for an evidence-producing source."
56);
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum StableIdError {
61 Empty,
62 TooLong,
63 InvalidCharacter,
64}
65
66impl fmt::Display for StableIdError {
67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68 let message = match self {
69 Self::Empty => "stable identifier is empty",
70 Self::TooLong => "stable identifier is too long",
71 Self::InvalidCharacter => "stable identifier contains an invalid character",
72 };
73 formatter.write_str(message)
74 }
75}
76
77impl Error for StableIdError {}
78
79fn validate_stable_id(value: &str) -> Result<(), StableIdError> {
80 if value.is_empty() {
81 return Err(StableIdError::Empty);
82 }
83 if value.len() > MAX_STABLE_ID_LEN {
84 return Err(StableIdError::TooLong);
85 }
86 if !value.bytes().all(|byte| {
87 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
88 }) {
89 return Err(StableIdError::InvalidCharacter);
90 }
91 Ok(())
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn stable_ids_reject_log_and_protocol_delimiters() {
100 for invalid in ["", "provider id", "provider\nforged", "provider\"id"] {
101 assert!(SourceId::parse(invalid).is_err(), "accepted {invalid:?}");
102 }
103 }
104}