#[cfg(feature = "fp-bindgen")]
use fp_bindgen::prelude::Serializable;
use serde::{Deserialize, Serialize};
use std::fmt::{self, Display, Formatter};
use thiserror::Error;
const MAX_LABEL_VALUE_LENGTH: usize = 63;
const MAX_LABEL_NAME_LENGTH: usize = 63;
const MAX_LABEL_PREFIX_LENGTH: usize = 253;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[cfg_attr(
feature = "fp-bindgen",
derive(Serializable),
fp(rust_module = "fiberplane_models::labels")
)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct Label {
pub key: String,
pub value: String,
}
impl Label {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
pub fn validate(&self) -> Result<(), LabelValidationError> {
Label::validate_key(&self.key)?;
Label::validate_value(&self.value)?;
Ok(())
}
pub fn validate_key(key: &str) -> Result<(), LabelValidationError> {
if key.is_empty() {
return Err(LabelValidationError::EmptyKey);
}
let (prefix, name) = match key.split_once('/') {
Some((prefix, name)) => (Some(prefix), name),
None => (None, key),
};
if name.is_empty() {
return Err(LabelValidationError::EmptyName);
}
if name.len() > MAX_LABEL_NAME_LENGTH {
return Err(LabelValidationError::NameTooLong);
}
let first = name.chars().next().unwrap();
let last = name.chars().last().unwrap();
if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
return Err(LabelValidationError::NameInvalidCharacters);
}
if name.chars().any(|c| !is_valid_label_char(c)) {
return Err(LabelValidationError::NameInvalidCharacters);
}
match prefix {
Some(prefix) => validate_prefix(prefix),
None => Ok(()),
}
}
pub fn validate_value(value: &str) -> Result<(), LabelValidationError> {
if !value.is_empty() {
if value.len() > MAX_LABEL_VALUE_LENGTH {
return Err(LabelValidationError::ValueTooLong);
}
let first = value.chars().next().unwrap();
let last = value.chars().last().unwrap();
if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
return Err(LabelValidationError::ValueInvalidCharacters);
}
if value.chars().any(|c| !is_valid_label_char(c)) {
return Err(LabelValidationError::ValueInvalidCharacters);
}
}
Ok(())
}
}
impl Display for Label {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(&self.key)?;
if !self.value.is_empty() {
f.write_str(&format!("={}", &self.value))?;
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Error)]
#[cfg_attr(
feature = "fp-bindgen",
derive(Serializable),
fp(rust_module = "fiberplane_models::labels")
)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum LabelValidationError {
#[error("The key in the label was empty")]
EmptyKey,
#[error("The name portion of the key was empty")]
EmptyName,
#[error("The name portion of the key was too long")]
NameTooLong,
#[error("The name portion of the key contains invalid characters")]
NameInvalidCharacters,
#[error("The prefix portion of the key was empty")]
EmptyPrefix,
#[error("The prefix portion of the key was too long")]
PrefixTooLong,
#[error("The prefix portion of the key contains invalid characters")]
PrefixInvalidCharacters,
#[error("The value is too long")]
ValueTooLong,
#[error("The value contains invalid characters")]
ValueInvalidCharacters,
}
fn is_valid_label_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
}
fn validate_prefix(prefix: &str) -> Result<(), LabelValidationError> {
if prefix.is_empty() {
return Err(LabelValidationError::EmptyPrefix);
}
if prefix.len() > MAX_LABEL_PREFIX_LENGTH {
return Err(LabelValidationError::PrefixTooLong);
}
for subdomain in prefix.split('.') {
if subdomain.is_empty() {
return Err(LabelValidationError::PrefixInvalidCharacters);
}
let first = subdomain.chars().next().unwrap();
let last = subdomain.chars().last().unwrap();
if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
return Err(LabelValidationError::PrefixInvalidCharacters);
}
if subdomain
.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '-')
{
return Err(LabelValidationError::ValueInvalidCharacters);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn label_key_valid() {
let keys = vec![
"key",
"key.with.dot",
"key_with_underscore",
"key-with-dash",
"key..with..double..dot",
"fiberplane.io/key",
"fiberplane.io/key.with.dot",
"fiberplane.io/key_with_underscore",
"fiberplane.io/key-with-dash",
];
for key in keys.into_iter() {
assert!(
Label::validate_key(key).is_ok(),
"Key \"{key}\" should have passed validation"
);
}
}
#[test]
fn label_key_invalid() {
let keys = vec![
"",
"too_long_name_too_long_name_too_long_name_too_long_name_too_long_name_",
"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",
"-name_start_with_non_alpha_numeric",
"name_end_with_non_alpha_numeric-",
"fiberplane..com/name",
"fiberplane.com/invalid/name",
"/name",
];
for key in keys.into_iter() {
assert!(
Label::validate_key(key).is_err(),
"Key \"{key}\" should have failed validation"
);
}
}
#[test]
fn label_value_valid() {
let values = vec![
"",
"value",
"value.with.dot",
"value_with_underscore",
"value-with-dash",
];
for value in values.into_iter() {
assert!(
Label::validate_value(value).is_ok(),
"Value \"{value}\" should have passed validation"
);
}
}
#[test]
fn label_value_invalid() {
let values = vec![
"too_long_name_too_long_name_too_long_name_too_long_name_too_long_name_",
"-value_starting_with_a_dash",
"value_ending_with_a_dash-",
];
for value in values.into_iter() {
assert!(
Label::validate_key(value).is_err(),
"Value \"{value}\" should have failed validation"
);
}
}
}