Skip to main content

dnspls_core/
identity.rs

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            /// Parses a bounded identifier safe for structured logs and protocols.
16            ///
17            /// # Errors
18            ///
19            /// Returns [`StableIdError`] for empty, oversized, or unsafe input.
20            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}