fiberplane_models/
labels.rs1#[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#[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 pub key: String,
24
25 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 pub fn validate(&self) -> Result<(), LabelValidationError> {
39 Label::validate_key(&self.key)?;
40 Label::validate_value(&self.value)?;
41
42 Ok(())
43 }
44
45 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 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 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 pub fn validate_value(value: &str) -> Result<(), LabelValidationError> {
96 if !value.is_empty() {
98 if value.len() > MAX_LABEL_VALUE_LENGTH {
99 return Err(LabelValidationError::ValueTooLong);
100 }
101
102 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
164fn 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 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}