pgroles_operator/
k8s_names.rs1use std::fmt;
23
24pub const MAX_LABEL_VALUE_LENGTH: usize = 63;
26
27pub const MAX_RESOURCE_NAME_LENGTH: usize = 253;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct InvalidIdentifier {
33 pub kind: &'static str,
35 pub value: String,
37}
38
39impl fmt::Display for InvalidIdentifier {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(f, "invalid Kubernetes {}: {:?}", self.kind, self.value)
42 }
43}
44
45impl std::error::Error for InvalidIdentifier {}
46
47pub fn is_valid_label_value(value: &str) -> bool {
52 if value.is_empty() {
53 return true;
54 }
55 if value.len() > MAX_LABEL_VALUE_LENGTH {
56 return false;
57 }
58 let bytes = value.as_bytes();
59 if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
60 return false;
61 }
62 bytes
63 .iter()
64 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
65}
66
67fn is_dns1123_label(value: &str) -> bool {
69 if value.is_empty() {
70 return false;
71 }
72 let bytes = value.as_bytes();
73 let is_lower_alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
74 if !is_lower_alnum(bytes[0]) || !is_lower_alnum(bytes[bytes.len() - 1]) {
75 return false;
76 }
77 bytes.iter().all(|b| is_lower_alnum(*b) || *b == b'-')
78}
79
80pub fn is_valid_resource_name(value: &str) -> bool {
86 if value.is_empty() || value.len() > MAX_RESOURCE_NAME_LENGTH {
87 return false;
88 }
89 value.split('.').all(is_dns1123_label)
90}
91
92pub fn truncate_name_prefix(prefix: &str, max_bytes: usize) -> &str {
101 let cut = if prefix.len() <= max_bytes {
102 prefix.len()
103 } else {
104 (0..=max_bytes)
106 .rev()
107 .find(|idx| prefix.is_char_boundary(*idx))
108 .unwrap_or(0)
109 };
110 prefix[..cut].trim_end_matches(['.', '-'])
111}
112
113pub fn sanitize_dns_label_segment(input: &str, fallback: &str) -> String {
127 debug_assert!(
128 is_dns1123_label(fallback),
129 "fallback {fallback:?} is not a valid DNS label"
130 );
131 let mut result = String::with_capacity(input.len());
132 let mut last_was_dash = false;
133
134 for ch in input.chars() {
135 let normalized = ch.to_ascii_lowercase();
136 if normalized.is_ascii_lowercase() || normalized.is_ascii_digit() {
137 result.push(normalized);
138 last_was_dash = false;
139 } else if !last_was_dash && !result.is_empty() {
140 result.push('-');
141 last_was_dash = true;
142 }
143 }
144
145 let trimmed = result.trim_matches('-');
146 if trimmed.is_empty() {
147 fallback.to_string()
148 } else {
149 trimmed.to_string()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
155pub struct LabelValue(String);
156
157impl LabelValue {
158 pub fn sanitize(value: &str) -> Self {
169 let sanitized: String = value
170 .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
171 .chars()
172 .map(|c| {
173 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
174 c
175 } else {
176 '_'
177 }
178 })
179 .take(MAX_LABEL_VALUE_LENGTH)
180 .collect();
181 let trimmed = sanitized.trim_end_matches(|c: char| !c.is_ascii_alphanumeric());
182 Self(trimmed.to_string())
183 }
184
185 pub fn try_new(value: &str) -> Result<Self, InvalidIdentifier> {
187 if is_valid_label_value(value) {
188 Ok(Self(value.to_string()))
189 } else {
190 Err(InvalidIdentifier {
191 kind: "label value",
192 value: value.to_string(),
193 })
194 }
195 }
196
197 pub fn as_str(&self) -> &str {
198 &self.0
199 }
200
201 pub fn into_string(self) -> String {
202 self.0
203 }
204}
205
206impl fmt::Display for LabelValue {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 f.write_str(&self.0)
209 }
210}
211
212impl From<LabelValue> for String {
213 fn from(value: LabelValue) -> Self {
214 value.0
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
220pub struct ResourceName(String);
221
222impl ResourceName {
223 pub fn try_new(value: impl Into<String>) -> Result<Self, InvalidIdentifier> {
225 let value = value.into();
226 if is_valid_resource_name(&value) {
227 Ok(Self(value))
228 } else {
229 Err(InvalidIdentifier {
230 kind: "resource name",
231 value,
232 })
233 }
234 }
235
236 pub fn as_str(&self) -> &str {
237 &self.0
238 }
239
240 pub fn into_string(self) -> String {
241 self.0
242 }
243}
244
245impl fmt::Display for ResourceName {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.write_str(&self.0)
248 }
249}
250
251impl From<ResourceName> for String {
252 fn from(value: ResourceName) -> Self {
253 value.0
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn label_value_sanitize_maps_and_trims() {
263 assert_eq!(
264 LabelValue::sanitize("orders-service").as_str(),
265 "orders-service"
266 );
267 assert_eq!(
268 LabelValue::sanitize("default/db-creds/DATABASE_URL").as_str(),
269 "default_db-creds_DATABASE_URL"
270 );
271 assert_eq!(
272 LabelValue::sanitize("_params_literal_appdb_").as_str(),
273 "params_literal_appdb"
274 );
275 assert_eq!(LabelValue::sanitize("___").as_str(), "");
277 }
278
279 #[test]
280 fn label_value_sanitize_trims_leading_before_truncating() {
281 let value = LabelValue::sanitize(&format!("__{}", "a".repeat(70)));
283 assert_eq!(value.as_str(), "a".repeat(MAX_LABEL_VALUE_LENGTH));
284 }
285
286 #[test]
287 fn label_value_sanitize_trims_separator_exposed_by_truncation() {
288 let value = LabelValue::sanitize(&format!("{}_x", "a".repeat(62)));
289 assert_eq!(value.as_str(), "a".repeat(62));
290 }
291
292 #[test]
293 fn label_value_try_new_rejects_invalid() {
294 assert!(LabelValue::try_new("orders").is_ok());
295 assert!(LabelValue::try_new("").is_ok());
296 assert!(LabelValue::try_new("_orders").is_err());
297 assert!(LabelValue::try_new("orders_").is_err());
298 assert!(LabelValue::try_new("orders/svc").is_err());
299 assert!(LabelValue::try_new(&"a".repeat(64)).is_err());
300 }
301
302 #[test]
303 fn resource_name_rejects_malformed_labels() {
304 assert!(is_valid_resource_name("db-creds"));
305 assert!(is_valid_resource_name("9db-creds"));
306 assert!(is_valid_resource_name("team.alpha.orders"));
307
308 assert!(!is_valid_resource_name(""));
309 assert!(!is_valid_resource_name("db..creds"));
310 assert!(!is_valid_resource_name("db-.creds"));
311 assert!(!is_valid_resource_name("-db-creds"));
312 assert!(!is_valid_resource_name("db-creds-"));
313 assert!(!is_valid_resource_name("DB-creds"));
314 assert!(!is_valid_resource_name("db_creds"));
315 assert!(!is_valid_resource_name(
316 &"a".repeat(MAX_RESOURCE_NAME_LENGTH + 1)
317 ));
318 }
319
320 #[test]
321 fn truncate_name_prefix_trims_exposed_separators() {
322 assert_eq!(truncate_name_prefix("orders", 10), "orders");
323 assert_eq!(truncate_name_prefix("orders-service", 7), "orders");
324 assert_eq!(truncate_name_prefix("team.alpha", 5), "team");
325 assert_eq!(truncate_name_prefix("team.alpha", 10), "team.alpha");
327 }
328
329 #[test]
330 fn truncate_name_prefix_respects_utf8_boundaries() {
331 let input = "é".repeat(5);
333 let truncated = truncate_name_prefix(&input, 5);
334 assert_eq!(truncated, "é".repeat(2));
335 assert!(truncated.len() <= 5);
336 }
337}