netsuke-build 0.1.0-beta3

A YAML-powered Ninja/Jinja hybrid build system.
//! Shared host pattern validation helpers.
//!
//! The module centralizes host pattern normalization so CLI parsing and
//! runtime policy evaluation agree on allowable host syntax. Matching a
//! concrete hostname against a parsed pattern lives in
//! `crate::host_matching`, which keeps this module's dependency surface
//! narrow enough for `build.rs` to compile it for man-page generation.

use crate::localization::{self, LocalizedMessage, keys};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;

/// Wrapper around a raw host pattern string awaiting normalization.
#[derive(Copy, Clone)]
struct HostPatternInput<'a>(&'a str);

impl<'a> HostPatternInput<'a> {
    /// Return the wrapped pattern string.
    const fn as_str(self) -> &'a str {
        self.0
    }
}
/// Shared validation state for one host pattern.
struct ValidationContext<'a> {
    /// Original pattern, used in error messages.
    original: HostPatternInput<'a>,
}

impl<'a> ValidationContext<'a> {
    /// Build a validation context around the original pattern.
    const fn new(original: HostPatternInput<'a>) -> Self {
        Self { original }
    }

    /// Validate a single DNS label, returning the error for each kind of
    /// violation.
    ///
    /// # Errors
    ///
    /// Returns an error for empty labels, invalid characters, invalid label
    /// edges, or labels longer than 63 characters.
    fn validate_label(&self, label: &str) -> Result<(), HostPatternError> {
        let original = self.original.as_str();
        if label.is_empty() {
            return Err(HostPatternError::EmptyLabel {
                pattern: original.to_owned(),
                message: localization::message(keys::HOST_PATTERN_EMPTY_LABEL)
                    .with_arg("pattern", original),
            });
        }
        if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
            return Err(HostPatternError::InvalidCharacters {
                pattern: original.to_owned(),
                message: localization::message(keys::HOST_PATTERN_INVALID_CHARS)
                    .with_arg("pattern", original),
            });
        }
        if label.starts_with('-') || label.ends_with('-') {
            return Err(HostPatternError::InvalidLabelEdge {
                pattern: original.to_owned(),
                message: localization::message(keys::HOST_PATTERN_INVALID_LABEL_EDGE)
                    .with_arg("pattern", original),
            });
        }
        if label.len() > 63 {
            return Err(HostPatternError::LabelTooLong {
                pattern: original.to_owned(),
                message: localization::message(keys::HOST_PATTERN_LABEL_TOO_LONG)
                    .with_arg("pattern", original),
            });
        }
        Ok(())
    }
}

/// Errors emitted when parsing host allowlist/blocklist patterns.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum HostPatternError {
    /// Input was empty or whitespace.
    #[error("{message}")]
    Empty {
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// The pattern erroneously included a URL scheme.
    #[error("{message}")]
    ContainsScheme {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// The pattern contained path delimiters.
    #[error("{message}")]
    ContainsSlash {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// Wildcard patterns must include a suffix after `*.`.
    #[error("{message}")]
    MissingSuffix {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// Patterns may not contain empty labels between dots.
    #[error("{message}")]
    EmptyLabel {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// Patterns must only contain alphanumeric characters or `-`.
    #[error("{message}")]
    InvalidCharacters {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// Labels must not begin or end with a hyphen.
    #[error("{message}")]
    InvalidLabelEdge {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// Individual labels may not exceed 63 characters.
    #[error("{message}")]
    LabelTooLong {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// The full host (including dots) may not exceed 255 characters.
    #[error("{message}")]
    HostTooLong {
        /// Original host pattern string.
        pattern: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
}

/// Normalize and validate a host pattern, returning the lowercased body and
/// wildcard flag.
///
/// # Errors
///
/// Returns an error for empty patterns, embedded scheme-like prefixes
/// (`://`), path separators (`/`), a wildcard prefix with no suffix, hosts
/// that exceed 255 characters, or labels that fail the DNS-label checks.
fn normalise_host_pattern(input: HostPatternInput<'_>) -> Result<(String, bool), HostPatternError> {
    let trimmed = input.as_str().trim();
    if trimmed.is_empty() {
        return Err(HostPatternError::Empty {
            message: localization::message(keys::HOST_PATTERN_EMPTY),
        });
    }
    if trimmed.contains("://") {
        return Err(HostPatternError::ContainsScheme {
            pattern: trimmed.to_owned(),
            message: localization::message(keys::HOST_PATTERN_CONTAINS_SCHEME)
                .with_arg("pattern", trimmed),
        });
    }
    if trimmed.contains('/') {
        return Err(HostPatternError::ContainsSlash {
            pattern: trimmed.to_owned(),
            message: localization::message(keys::HOST_PATTERN_CONTAINS_SLASH)
                .with_arg("pattern", trimmed),
        });
    }

    let (wildcard, host_body) = if let Some(suffix) = trimmed.strip_prefix("*.") {
        if suffix.is_empty() {
            return Err(HostPatternError::MissingSuffix {
                pattern: trimmed.to_owned(),
                message: localization::message(keys::HOST_PATTERN_MISSING_SUFFIX)
                    .with_arg("pattern", trimmed),
            });
        }
        (true, suffix)
    } else {
        (false, trimmed)
    };

    let normalized = host_body.to_ascii_lowercase();
    let mut total_len = 0usize;
    let ctx = ValidationContext::new(HostPatternInput(trimmed));
    for (index, label) in normalized.split('.').enumerate() {
        ctx.validate_label(label)?;
        total_len += label.len() + usize::from(index > 0);
    }
    if total_len > 255 {
        return Err(HostPatternError::HostTooLong {
            pattern: trimmed.to_owned(),
            message: localization::message(keys::HOST_PATTERN_TOO_LONG)
                .with_arg("pattern", trimmed),
        });
    }

    Ok((normalized, wildcard))
}

/// Canonical host pattern storing the normalized body and wildcard flag.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostPattern {
    /// Normalized lowercase host body, without the wildcard prefix.
    pub(crate) pattern: String,
    /// Whether the pattern matches any subdomain of the body.
    pub(crate) wildcard: bool,
}

impl HostPattern {
    /// Parse a host pattern into its canonical representation.
    ///
    /// # Errors
    ///
    /// Returns an error when the pattern is empty, includes invalid
    /// characters, or uses a wildcard without a suffix.
    pub fn parse(pattern: &str) -> Result<Self, HostPatternError> {
        let (normalized, wildcard) = normalise_host_pattern(HostPatternInput(pattern))?;
        Ok(Self {
            pattern: normalized,
            wildcard,
        })
    }
}

impl<'a> TryFrom<&'a str> for HostPattern {
    type Error = HostPatternError;

    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
        Self::parse(value)
    }
}

impl TryFrom<String> for HostPattern {
    type Error = HostPatternError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::parse(&value)
    }
}

impl FromStr for HostPattern {
    type Err = HostPatternError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::parse(value)
    }
}

impl Serialize for HostPattern {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        if self.wildcard {
            let text = format!("*.{}", self.pattern);
            serializer.serialize_str(&text)
        } else {
            serializer.serialize_str(&self.pattern)
        }
    }
}

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

#[cfg(test)]
mod tests {
    //! Unit tests for host pattern parsing and normalisation.
    use super::*;

    use anyhow::{Result, ensure};
    use rstest::rstest;
    use test_support::{localizer_test_lock, set_en_localizer};

    #[rstest]
    #[case("example.com", false)]
    #[case("*.example.com", true)]
    fn host_pattern_parse_detects_wildcard(
        #[case] pattern: &str,
        #[case] wildcard: bool,
    ) -> Result<()> {
        let parsed = HostPattern::parse(pattern)?;
        ensure!(
            parsed.wildcard == wildcard,
            "expected wildcard {wildcard} for pattern {pattern}",
        );
        Ok(())
    }

    #[rstest]
    #[case("-example.com")]
    #[case("example-.com")]
    #[case("exa mple.com")]
    #[case("*.bad-.test")]
    fn host_pattern_rejects_invalid_shapes(#[case] pattern: &str) {
        let _lock = localizer_test_lock().expect("localizer test lock poisoned");
        let _guard = set_en_localizer();
        let err = HostPattern::parse(pattern).expect_err("invalid pattern should fail");
        let message = err.to_string();
        assert!(
            message.contains("Host pattern"),
            "error message should mention host pattern validation: {message}"
        );
    }
}