secrets-rs 0.1.2

Safely surface secrets to Rust applications — masked by default, explicit opt-in for real values
Documentation
use std::{fmt, str::FromStr};

use crate::error::UrnParseError;

/// A secret URN of the form `urn:secrets-rs:<source_id>:<name>`.
///
/// The scheme (`urn`) and NID (`secrets-rs`) are validated case-insensitively
/// per RFC 8141. The `source_id` and `name` are stored as-is; case sensitivity
/// is determined by the source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Urn {
    /// Identifies which source holds this secret.
    pub source_id: String,
    /// The name of the secret within the source.
    pub name: String,
}

impl Urn {
    /// Creates a `Urn` by parsing a URN string.
    pub fn parse(s: &str) -> Result<Self, UrnParseError> {
        s.parse()
    }
}

impl FromStr for Urn {
    type Err = UrnParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let parts: Vec<&str> = s.splitn(4, ':').collect();
        if parts.len() != 4 {
            // splitn(4) on "a:b:c" yields ["a","b","c"] — only 3 parts
            return Err(UrnParseError::WrongSegmentCount(parts.len()));
        }

        if !parts[0].eq_ignore_ascii_case("urn") {
            return Err(UrnParseError::InvalidScheme(parts[0].to_owned()));
        }
        if !parts[1].eq_ignore_ascii_case("secrets-rs") {
            return Err(UrnParseError::InvalidNid(parts[1].to_owned()));
        }
        if parts[2].is_empty() {
            return Err(UrnParseError::EmptySourceId);
        }
        if parts[3].is_empty() {
            return Err(UrnParseError::EmptyName);
        }

        Ok(Urn {
            source_id: parts[2].to_owned(),
            name: parts[3].to_owned(),
        })
    }
}

impl fmt::Display for Urn {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "urn:secrets-rs:{}:{}", self.source_id, self.name)
    }
}

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

    #[test]
    fn parses_valid_urn() {
        let urn: Urn = "urn:secrets-rs:env:MY_SECRET".parse().unwrap();
        assert_eq!(urn.source_id, "env");
        assert_eq!(urn.name, "MY_SECRET");
    }

    #[test]
    fn case_insensitive_scheme_and_nid() {
        let urn: Urn = "URN:SECRETS-RS:env:KEY".parse().unwrap();
        assert_eq!(urn.source_id, "env");
        assert_eq!(urn.name, "KEY");
    }

    #[test]
    fn display_uses_lowercase_prefix() {
        let urn: Urn = "urn:secrets-rs:env:KEY".parse().unwrap();
        assert_eq!(urn.to_string(), "urn:secrets-rs:env:KEY");
    }

    #[test]
    fn name_may_contain_colons() {
        // splitn(4) means everything after the third colon is the name
        let urn: Urn = "urn:secrets-rs:env:a:b:c".parse().unwrap();
        assert_eq!(urn.name, "a:b:c");
    }

    #[test]
    fn error_on_wrong_scheme() {
        let err = "nrn:secrets-rs:env:KEY".parse::<Urn>().unwrap_err();
        assert!(matches!(err, UrnParseError::InvalidScheme(_)));
    }

    #[test]
    fn error_on_wrong_nid() {
        let err = "urn:other:env:KEY".parse::<Urn>().unwrap_err();
        assert!(matches!(err, UrnParseError::InvalidNid(_)));
    }

    #[test]
    fn error_on_too_few_segments() {
        let err = "urn:secrets-rs:env".parse::<Urn>().unwrap_err();
        assert!(matches!(err, UrnParseError::WrongSegmentCount(_)));
    }

    #[test]
    fn error_on_empty_source_id() {
        let err = "urn:secrets-rs::KEY".parse::<Urn>().unwrap_err();
        assert_eq!(err, UrnParseError::EmptySourceId);
    }

    #[test]
    fn error_on_empty_name() {
        let err = "urn:secrets-rs:env:".parse::<Urn>().unwrap_err();
        assert_eq!(err, UrnParseError::EmptyName);
    }
}