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}