dnspls-core 0.1.0

Provider-neutral evidence and decision kernel for DNSpls
Documentation
use std::{error::Error, fmt};

use serde::{Deserialize, Serialize};

const MAX_STABLE_ID_LEN: usize = 128;

macro_rules! stable_id {
    ($name:ident, $description:literal) => {
        #[doc = $description]
        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
        #[serde(transparent)]
        pub struct $name(Box<str>);

        impl $name {
            /// Parses a bounded identifier safe for structured logs and protocols.
            ///
            /// # Errors
            ///
            /// Returns [`StableIdError`] for empty, oversized, or unsafe input.
            pub fn parse(value: impl Into<Box<str>>) -> Result<Self, StableIdError> {
                let value = value.into();
                validate_stable_id(&value)?;
                Ok(Self(value))
            }

            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(self.as_str())
            }
        }

        impl<'de> Deserialize<'de> for $name {
            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
            where
                D: serde::Deserializer<'de>,
            {
                let value = String::deserialize(deserializer)?;
                Self::parse(value.into_boxed_str()).map_err(serde::de::Error::custom)
            }
        }
    };
}

stable_id!(
    ObservationId,
    "Stable identifier for one immutable observation."
);
stable_id!(
    SourceId,
    "Stable identifier for an evidence-producing source."
);

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StableIdError {
    Empty,
    TooLong,
    InvalidCharacter,
}

impl fmt::Display for StableIdError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self {
            Self::Empty => "stable identifier is empty",
            Self::TooLong => "stable identifier is too long",
            Self::InvalidCharacter => "stable identifier contains an invalid character",
        };
        formatter.write_str(message)
    }
}

impl Error for StableIdError {}

fn validate_stable_id(value: &str) -> Result<(), StableIdError> {
    if value.is_empty() {
        return Err(StableIdError::Empty);
    }
    if value.len() > MAX_STABLE_ID_LEN {
        return Err(StableIdError::TooLong);
    }
    if !value.bytes().all(|byte| {
        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
    }) {
        return Err(StableIdError::InvalidCharacter);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stable_ids_reject_log_and_protocol_delimiters() {
        for invalid in ["", "provider id", "provider\nforged", "provider\"id"] {
            assert!(SourceId::parse(invalid).is_err(), "accepted {invalid:?}");
        }
    }
}