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