dnspls-core 0.1.0

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

use serde::{Deserialize, Serialize};

/// Milliseconds from the Unix epoch.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UnixMillis(i64);

impl UnixMillis {
    pub const fn new(value: i64) -> Self {
        Self(value)
    }

    pub const fn get(self) -> i64 {
        self.0
    }

    /// Adds a non-negative duration without saturating.
    ///
    /// # Errors
    ///
    /// Returns [`TimeError::Overflow`] if the result is not representable.
    pub fn checked_add(self, duration: DurationMillis) -> Result<Self, TimeError> {
        let duration = i64::try_from(duration.0).map_err(|_| TimeError::Overflow)?;
        self.0
            .checked_add(duration)
            .map(Self)
            .ok_or(TimeError::Overflow)
    }
}

/// A non-negative millisecond duration.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DurationMillis(u64);

impl DurationMillis {
    pub const fn new(value: u64) -> Self {
        Self(value)
    }

    pub const fn get(self) -> u64 {
        self.0
    }
}

/// The validity interval attached to one observation.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct FreshWindow {
    observed_at: UnixMillis,
    expires_at: UnixMillis,
}

impl FreshWindow {
    /// Builds a validity window after checking temporal ordering.
    ///
    /// # Errors
    ///
    /// Returns [`TimeError::InvertedWindow`] when expiry precedes observation.
    pub fn new(observed_at: UnixMillis, expires_at: UnixMillis) -> Result<Self, TimeError> {
        if expires_at < observed_at {
            return Err(TimeError::InvertedWindow);
        }
        Ok(Self {
            observed_at,
            expires_at,
        })
    }

    /// Builds a validity window from an observation time and duration.
    ///
    /// # Errors
    ///
    /// Returns [`TimeError::Overflow`] if expiry is not representable.
    pub fn for_duration(
        observed_at: UnixMillis,
        duration: DurationMillis,
    ) -> Result<Self, TimeError> {
        Self::new(observed_at, observed_at.checked_add(duration)?)
    }

    pub const fn observed_at(self) -> UnixMillis {
        self.observed_at
    }

    pub const fn expires_at(self) -> UnixMillis {
        self.expires_at
    }

    /// The interval is inclusive at both boundaries.
    pub const fn state_at(self, now: UnixMillis) -> TemporalState {
        if now.0 < self.observed_at.0 {
            TemporalState::NotYetValid
        } else if now.0 <= self.expires_at.0 {
            TemporalState::Fresh
        } else {
            TemporalState::Stale
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TemporalState {
    NotYetValid,
    Fresh,
    Stale,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TimeError {
    InvertedWindow,
    Overflow,
}

impl fmt::Display for TimeError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self {
            Self::InvertedWindow => "freshness expiry precedes observation time",
            Self::Overflow => "timestamp arithmetic overflowed",
        };
        formatter.write_str(message)
    }
}

impl Error for TimeError {}

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

    #[test]
    fn freshness_boundaries_are_explicit() {
        let window = FreshWindow::new(UnixMillis::new(10), UnixMillis::new(20)).unwrap();
        assert_eq!(
            window.state_at(UnixMillis::new(9)),
            TemporalState::NotYetValid
        );
        assert_eq!(window.state_at(UnixMillis::new(10)), TemporalState::Fresh);
        assert_eq!(window.state_at(UnixMillis::new(20)), TemporalState::Fresh);
        assert_eq!(window.state_at(UnixMillis::new(21)), TemporalState::Stale);
    }
}