Skip to main content

pgroles_operator/
k8s_names.rs

1//! Construction and validation of Kubernetes identifiers.
2//!
3//! The API server rejects objects whose names or label values break the rules
4//! in the [object names reference][names]. Client-side builders that truncate a
5//! long input are the usual source of invalid values: the cut can land on a
6//! separator and leave a value that no longer starts and ends with an
7//! alphanumeric, and the resulting rejection surfaces as a policy that stops
8//! reconciling rather than as an obvious bug.
9//!
10//! Every identifier the operator derives from user input goes through this
11//! module so those rules live in exactly one place. Two shapes matter:
12//!
13//! - **Label values** ([`LabelValue`]) — at most 63 characters of
14//!   alphanumerics, `.`, `-`, and `_`, starting and ending alphanumeric. The
15//!   empty string is also valid.
16//! - **Resource names** ([`ResourceName`]) — RFC 1123 DNS subdomains: at most
17//!   253 characters of dot-separated labels, each label lowercase
18//!   alphanumerics and `-`, starting and ending alphanumeric.
19//!
20//! [names]: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/
21
22use std::fmt;
23
24/// Maximum length of a Kubernetes label value.
25pub const MAX_LABEL_VALUE_LENGTH: usize = 63;
26
27/// Maximum length of a Kubernetes resource name (RFC 1123 DNS subdomain).
28pub const MAX_RESOURCE_NAME_LENGTH: usize = 253;
29
30/// An identifier that does not satisfy the Kubernetes rules for its position.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct InvalidIdentifier {
33    /// What kind of identifier was expected (e.g. `"label value"`).
34    pub kind: &'static str,
35    /// The offending value.
36    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
47/// Is `value` a valid Kubernetes label value?
48///
49/// Label values are at most 63 characters of alphanumerics, `.`, `-`, and `_`,
50/// and must start and end with an alphanumeric. The empty string is valid.
51pub 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
67/// Is `value` a valid RFC 1123 DNS label (one dot-free segment of a name)?
68fn 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
80/// Is `value` a valid Kubernetes resource name (RFC 1123 DNS subdomain)?
81///
82/// Stricter than a first/last character check: every dot-separated label must
83/// itself be non-empty and start and end with a lowercase alphanumeric, so
84/// names such as `db..creds` and `db-.creds` are correctly rejected.
85pub 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
92/// Truncate a resource-name prefix to at most `max_bytes`, then trim any `.`
93/// or `-` the cut exposed.
94///
95/// Truncation respects UTF-8 boundaries so the result is always a valid `str`.
96/// Trailing separators must go because callers append their own suffix: a
97/// prefix ending in `.` would start a new DNS label with the suffix's leading
98/// `-`, which the API server rejects. Only `.` and `-` are trimmed, so a
99/// prefix that starts with an alphanumeric can never be emptied.
100pub fn truncate_name_prefix(prefix: &str, max_bytes: usize) -> &str {
101    let cut = if prefix.len() <= max_bytes {
102        prefix.len()
103    } else {
104        // Largest char boundary at or below max_bytes.
105        (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
113/// Derive a single RFC 1123 DNS label from arbitrary input, for use as one
114/// segment of a composed resource name.
115///
116/// Input is lowercased; every run of characters outside `[a-z0-9]` collapses to
117/// a single `-`; leading and trailing `-` are dropped. `fallback` is returned if
118/// nothing survives, so the result is always a usable segment.
119///
120/// Like [`LabelValue::sanitize`] this is lossy and **not injective** — callers
121/// composing several segments must not treat the result as an identity.
122///
123/// `fallback` is returned verbatim and is the one value this function cannot
124/// make safe, so it must already be a valid DNS 1123 label. Debug builds assert
125/// it; callers pass literals.
126pub 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/// A validated Kubernetes label value.
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
155pub struct LabelValue(String);
156
157impl LabelValue {
158    /// Derive a valid label value from arbitrary input.
159    ///
160    /// Characters outside the permitted set become `_`, leading separators are
161    /// dropped before the 63-character cut so they do not consume budget that
162    /// is then discarded, and any separator the cut exposes is trimmed.
163    ///
164    /// This is lossy and therefore **not injective**: distinct inputs can
165    /// produce the same label value. Do not use the result as the sole identity
166    /// for anything that drives deletion — see [`crate::plan`] for the
167    /// hash-based approach used where uniqueness matters.
168    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    /// Accept `value` only if it is already a valid label value.
186    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/// A validated Kubernetes resource name.
219#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
220pub struct ResourceName(String);
221
222impl ResourceName {
223    /// Accept `value` only if it is a valid resource name.
224    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        // Only separators collapses to the empty string, which is a valid label.
276        assert_eq!(LabelValue::sanitize("___").as_str(), "");
277    }
278
279    #[test]
280    fn label_value_sanitize_trims_leading_before_truncating() {
281        // Leading separators must not consume the 63-character budget.
282        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        // Interior separators are preserved.
326        assert_eq!(truncate_name_prefix("team.alpha", 10), "team.alpha");
327    }
328
329    #[test]
330    fn truncate_name_prefix_respects_utf8_boundaries() {
331        // `é` is two bytes: an odd budget must not split it.
332        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}