Skip to main content

fiberplane_models/
labels.rs

1#[cfg(feature = "fp-bindgen")]
2use fp_bindgen::prelude::Serializable;
3use serde::{Deserialize, Serialize};
4use std::fmt::{self, Display, Formatter};
5use thiserror::Error;
6
7const MAX_LABEL_VALUE_LENGTH: usize = 63;
8const MAX_LABEL_NAME_LENGTH: usize = 63;
9const MAX_LABEL_PREFIX_LENGTH: usize = 253;
10
11/// Labels that are associated with a Notebook.
12#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
13#[cfg_attr(
14    feature = "fp-bindgen",
15    derive(Serializable),
16    fp(rust_module = "fiberplane_models::labels")
17)]
18#[non_exhaustive]
19#[serde(rename_all = "camelCase")]
20pub struct Label {
21    /// The key of the label. Should be unique for a single Notebook.
22    pub key: String,
23
24    /// The value of the label. Can be left empty.
25    pub value: String,
26}
27
28impl Label {
29    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
30        Self {
31            key: key.into(),
32            value: value.into(),
33        }
34    }
35
36    /// Validates the key and value.
37    pub fn validate(&self) -> Result<(), LabelValidationError> {
38        Label::validate_key(&self.key)?;
39        Label::validate_value(&self.value)?;
40
41        Ok(())
42    }
43
44    /// A key is considered valid if it adheres to the following criteria:
45    /// It can contain two segments, a prefix and a name, the name segment has
46    /// the following criteria:
47    /// - must be 63 characters or less (cannot be empty)
48    /// - must begin and end with an alphanumeric character ([a-z0-9A-Z])
49    /// - could contain dashes (-), underscores (_), dots (.), and alphanumerics between
50    ///
51    /// The prefix is optional, if specified must follow the following criteria:
52    /// - must be 253 characters or less
53    /// - must be a valid DNS subdomain
54    pub fn validate_key(key: &str) -> Result<(), LabelValidationError> {
55        if key.is_empty() {
56            return Err(LabelValidationError::EmptyKey);
57        }
58
59        let (prefix, name) = match key.split_once('/') {
60            Some((prefix, name)) => (Some(prefix), name),
61            None => (None, key),
62        };
63
64        // Validation of the name portion
65        if name.is_empty() {
66            return Err(LabelValidationError::EmptyName);
67        }
68
69        if name.len() > MAX_LABEL_NAME_LENGTH {
70            return Err(LabelValidationError::NameTooLong);
71        }
72
73        // Check the first and last characters
74        let first = name.chars().next().unwrap();
75        let last = name.chars().last().unwrap();
76        if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
77            return Err(LabelValidationError::NameInvalidCharacters);
78        }
79
80        if name.chars().any(|c| !is_valid_label_char(c)) {
81            return Err(LabelValidationError::NameInvalidCharacters);
82        }
83
84        match prefix {
85            Some(prefix) => validate_prefix(prefix),
86            None => Ok(()),
87        }
88    }
89
90    /// A value is considered valid if it adheres to the following criteria:
91    /// - must be 63 characters or less (can be empty)
92    /// - unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z])
93    /// - could contain dashes (-), underscores (_), dots (.), and alphanumerics between
94    pub fn validate_value(value: &str) -> Result<(), LabelValidationError> {
95        // Validation of the value (only if it contains something)
96        if !value.is_empty() {
97            if value.len() > MAX_LABEL_VALUE_LENGTH {
98                return Err(LabelValidationError::ValueTooLong);
99            }
100
101            // Check the first and last characters
102            let first = value.chars().next().unwrap();
103            let last = value.chars().last().unwrap();
104            if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
105                return Err(LabelValidationError::ValueInvalidCharacters);
106            }
107
108            if value.chars().any(|c| !is_valid_label_char(c)) {
109                return Err(LabelValidationError::ValueInvalidCharacters);
110            }
111        }
112        Ok(())
113    }
114}
115
116impl Display for Label {
117    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
118        f.write_str(&self.key)?;
119        if !self.value.is_empty() {
120            f.write_str(&format!("={}", &self.value))?;
121        }
122        Ok(())
123    }
124}
125
126#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Error)]
127#[cfg_attr(
128    feature = "fp-bindgen",
129    derive(Serializable),
130    fp(rust_module = "fiberplane_models::labels")
131)]
132#[non_exhaustive]
133#[serde(rename_all = "snake_case")]
134pub enum LabelValidationError {
135    #[error("The key in the label was empty")]
136    EmptyKey,
137
138    #[error("The name portion of the key was empty")]
139    EmptyName,
140
141    #[error("The name portion of the key was too long")]
142    NameTooLong,
143
144    #[error("The name portion of the key contains invalid characters")]
145    NameInvalidCharacters,
146
147    #[error("The prefix portion of the key was empty")]
148    EmptyPrefix,
149
150    #[error("The prefix portion of the key was too long")]
151    PrefixTooLong,
152
153    #[error("The prefix portion of the key contains invalid characters")]
154    PrefixInvalidCharacters,
155
156    #[error("The value is too long")]
157    ValueTooLong,
158
159    #[error("The value contains invalid characters")]
160    ValueInvalidCharacters,
161}
162
163/// Returns whether the given character is valid to be used in a label.
164///
165/// Note that additional restrictions apply to a label's first and last
166/// characters.
167fn is_valid_label_char(c: char) -> bool {
168    c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
169}
170
171fn validate_prefix(prefix: &str) -> Result<(), LabelValidationError> {
172    if prefix.is_empty() {
173        return Err(LabelValidationError::EmptyPrefix);
174    }
175
176    if prefix.len() > MAX_LABEL_PREFIX_LENGTH {
177        return Err(LabelValidationError::PrefixTooLong);
178    }
179
180    for subdomain in prefix.split('.') {
181        if subdomain.is_empty() {
182            return Err(LabelValidationError::PrefixInvalidCharacters);
183        }
184
185        // Check the first and last characters
186        let first = subdomain.chars().next().unwrap();
187        let last = subdomain.chars().last().unwrap();
188        if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
189            return Err(LabelValidationError::PrefixInvalidCharacters);
190        }
191
192        if subdomain
193            .chars()
194            .any(|c| !c.is_ascii_alphanumeric() && c != '-')
195        {
196            return Err(LabelValidationError::ValueInvalidCharacters);
197        }
198    }
199
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn label_key_valid() {
209        let keys = vec![
210            "key",
211            "key.with.dot",
212            "key_with_underscore",
213            "key-with-dash",
214            "key..with..double..dot",
215            "fiberplane.io/key",
216            "fiberplane.io/key.with.dot",
217            "fiberplane.io/key_with_underscore",
218            "fiberplane.io/key-with-dash",
219        ];
220        for key in keys.into_iter() {
221            assert!(
222                Label::validate_key(key).is_ok(),
223                "Key \"{key}\" should have passed validation"
224            );
225        }
226    }
227
228    #[test]
229    fn label_key_invalid() {
230        let keys = vec![
231            "",
232            "too_long_name_too_long_name_too_long_name_too_long_name_too_long_name_",
233            "fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.fiberplane.com/name",
234            "-name_start_with_non_alpha_numeric",
235            "name_end_with_non_alpha_numeric-",
236            "fiberplane..com/name",
237            "fiberplane.com/invalid/name",
238            "/name",
239        ];
240        for key in keys.into_iter() {
241            assert!(
242                Label::validate_key(key).is_err(),
243                "Key \"{key}\" should have failed validation"
244            );
245        }
246    }
247
248    #[test]
249    fn label_value_valid() {
250        let values = vec![
251            "",
252            "value",
253            "value.with.dot",
254            "value_with_underscore",
255            "value-with-dash",
256        ];
257        for value in values.into_iter() {
258            assert!(
259                Label::validate_value(value).is_ok(),
260                "Value \"{value}\" should have passed validation"
261            );
262        }
263    }
264
265    #[test]
266    fn label_value_invalid() {
267        let values = vec![
268            "too_long_name_too_long_name_too_long_name_too_long_name_too_long_name_",
269            "-value_starting_with_a_dash",
270            "value_ending_with_a_dash-",
271        ];
272        for value in values.into_iter() {
273            assert!(
274                Label::validate_key(value).is_err(),
275                "Value \"{value}\" should have failed validation"
276            );
277        }
278    }
279}