Skip to main content

aperture_cli/config/
context_name.rs

1//! Validated API context name type.
2//!
3//! This module provides `ApiContextName`, a newtype wrapper around `String`
4//! that enforces strict naming rules to prevent path traversal attacks and
5//! invalid filesystem states when API names are used to construct file paths.
6
7use crate::error::Error;
8use std::fmt;
9
10/// Maximum allowed length for an API context name.
11const MAX_NAME_LENGTH: usize = 64;
12
13/// A validated API context name.
14///
15/// API context names are used to construct file paths for spec storage and
16/// caching, so they must be strictly validated to prevent path traversal.
17///
18/// # Naming Rules
19///
20/// - Must start with an ASCII letter or digit
21/// - May contain only ASCII letters, digits, dots (`.`), hyphens (`-`), or underscores (`_`)
22/// - Maximum length: 64 characters
23/// - No path separators, no leading dots, no whitespace
24///
25/// # Examples
26///
27/// ```
28/// use aperture_cli::config::context_name::ApiContextName;
29///
30/// // Valid names
31/// assert!(ApiContextName::new("my-api").is_ok());
32/// assert!(ApiContextName::new("api_v2").is_ok());
33/// assert!(ApiContextName::new("foo.bar").is_ok());
34/// assert!(ApiContextName::new("API123").is_ok());
35///
36/// // Invalid names
37/// assert!(ApiContextName::new("../foo").is_err());
38/// assert!(ApiContextName::new("foo/bar").is_err());
39/// assert!(ApiContextName::new(".hidden").is_err());
40/// assert!(ApiContextName::new("").is_err());
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct ApiContextName(String);
44
45impl ApiContextName {
46    /// Creates a new validated `ApiContextName`.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the name:
51    /// - Is empty
52    /// - Exceeds 64 characters
53    /// - Does not start with an ASCII letter or digit
54    /// - Contains characters other than ASCII letters, digits, `.`, `-`, or `_`
55    pub fn new(name: &str) -> Result<Self, Error> {
56        if name.is_empty() {
57            return Err(Error::invalid_api_context_name(
58                name,
59                "name cannot be empty",
60            ));
61        }
62
63        if name.len() > MAX_NAME_LENGTH {
64            return Err(Error::invalid_api_context_name(
65                name,
66                format!(
67                    "name exceeds maximum length of {MAX_NAME_LENGTH} characters ({} given)",
68                    name.len()
69                ),
70            ));
71        }
72
73        // First character must be ASCII alphanumeric.
74        // Safety: we verified `name` is non-empty above.
75        let first = name.as_bytes()[0];
76        if !first.is_ascii_alphanumeric() {
77            return Err(Error::invalid_api_context_name(
78                name,
79                "name must start with an ASCII letter or digit",
80            ));
81        }
82
83        // All characters must be ASCII alphanumeric, dot, hyphen, or underscore
84        if let Some(invalid) = name
85            .chars()
86            .find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '-' && *c != '_')
87        {
88            return Err(Error::invalid_api_context_name(
89                name,
90                format!("contains invalid character '{invalid}'"),
91            ));
92        }
93
94        Ok(Self(name.to_string()))
95    }
96
97    /// Returns the validated name as a string slice.
98    #[must_use]
99    pub fn as_str(&self) -> &str {
100        &self.0
101    }
102}
103
104impl AsRef<str> for ApiContextName {
105    fn as_ref(&self) -> &str {
106        &self.0
107    }
108}
109
110impl std::ops::Deref for ApiContextName {
111    type Target = str;
112
113    fn deref(&self) -> &str {
114        &self.0
115    }
116}
117
118impl fmt::Display for ApiContextName {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(&self.0)
121    }
122}