use std::sync::{LazyLock, OnceLock};
pub const DEFAULT_IDENTITY_NAME: &str = "pingap";
const MAX_LABELS: usize = 16;
const MAX_LABEL_KEY: usize = 32;
const MAX_LABEL_VALUE: usize = 128;
const MAX_NAME: usize = 64;
#[derive(Debug, Clone, PartialEq)]
pub struct Identity {
pub name: String,
pub labels: Vec<(String, String)>,
}
impl Default for Identity {
fn default() -> Self {
Self {
name: DEFAULT_IDENTITY_NAME.to_string(),
labels: vec![],
}
}
}
impl Identity {
pub fn new(name: Option<&str>, labels: Vec<(String, String)>) -> Self {
let name = name
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or(DEFAULT_IDENTITY_NAME);
Self {
name: name.to_string(),
labels,
}
}
pub fn is_named(&self) -> bool {
self.name != DEFAULT_IDENTITY_NAME
}
pub fn label(&self, key: &str) -> Option<&str> {
self.labels
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value.as_str())
}
}
pub fn parse_identity_name(name: Option<&str>) -> Result<(), String> {
let name = name.unwrap_or_default().trim();
if name.len() > MAX_NAME || name.chars().any(char::is_control) {
return Err(format!(
"name should be one line of at most {MAX_NAME} bytes"
));
}
Ok(())
}
static IDENTITY: OnceLock<Identity> = OnceLock::new();
static DEFAULT_IDENTITY: LazyLock<Identity> = LazyLock::new(Identity::default);
pub fn set_identity(identity: Identity) {
IDENTITY.get_or_init(|| identity);
}
pub fn get_identity() -> &'static Identity {
IDENTITY.get().unwrap_or(&DEFAULT_IDENTITY)
}
pub fn parse_labels(
entries: &[String],
) -> Result<Vec<(String, String)>, String> {
if entries.len() > MAX_LABELS {
return Err(format!(
"labels: {} of them, the most is {MAX_LABELS}",
entries.len()
));
}
let mut labels: Vec<(String, String)> = Vec::with_capacity(entries.len());
for item in entries {
let invalid = |what: &str| {
let key = item.split(':').next().unwrap_or_default().trim();
format!("labels: {key:?} {what}")
};
let (key, value) = item
.split_once(':')
.ok_or_else(|| invalid("should be `key:value`"))?;
let (key, value) = (key.trim(), value.trim());
let named = !key.is_empty()
&& key.len() <= MAX_LABEL_KEY
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !named {
return Err(invalid(
"should be a key of letters, digits, _ and -, at most 32",
));
}
if value.is_empty() {
return Err(invalid("has no value"));
}
if value.starts_with('$') {
return Err(invalid(
"has a value that starts with $, which is read as nothing: the value of a label comes from the environment as $ENV:NAME",
));
}
if value.len() > MAX_LABEL_VALUE || value.chars().any(char::is_control)
{
return Err(invalid(
"has a value that is longer than 128 bytes, or not one line of text",
));
}
if labels.iter().any(|(name, _)| name == key) {
return Err(invalid("is there twice"));
}
labels.push((key.to_string(), value.to_string()));
}
Ok(labels)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_identity() {
let identity = Identity::default();
assert_eq!("pingap", identity.name);
assert_eq!(false, identity.is_named());
assert_eq!(None, identity.label("cluster"));
assert_eq!("pingap", Identity::new(None, vec![]).name);
assert_eq!("pingap", Identity::new(Some(" "), vec![]).name);
let labels = parse_labels(&[
"cluster:sh-prod".to_string(),
" env : prod ".to_string(),
])
.unwrap();
let identity = Identity::new(Some(" edge-sh "), labels);
assert_eq!("edge-sh", identity.name);
assert_eq!(true, identity.is_named());
assert_eq!(Some("sh-prod"), identity.label("cluster"));
assert_eq!(Some("prod"), identity.label("env"));
assert_eq!(None, identity.label("region"));
assert_eq!(Ok(()), parse_identity_name(None));
assert_eq!(Ok(()), parse_identity_name(Some("edge-sh 上海")));
assert_eq!(true, parse_identity_name(Some("a\nb")).is_err());
assert_eq!(true, parse_identity_name(Some(&"n".repeat(65))).is_err());
assert_eq!("pingap", get_identity().name);
}
#[test]
fn test_parse_labels() {
let parse = |entries: &[&str]| {
let entries: Vec<String> =
entries.iter().map(|item| item.to_string()).collect();
parse_labels(&entries)
};
assert_eq!(Ok(vec![]), parse(&[]));
assert_eq!(
Ok(vec![("zone".to_string(), "cn-east:a".to_string())]),
parse(&["zone:cn-east:a"])
);
for (entries, reason) in [
(vec!["cluster"], "\"cluster\" should be `key:value`"),
(vec![":sh"], "\"\" should be a key of"),
(vec!["k8s.cluster:sh"], "\"k8s.cluster\" should be a key of"),
(vec!["my cluster:sh"], "should be a key of"),
(vec!["cluster:"], "\"cluster\" has no value"),
(vec!["cluster: "], "\"cluster\" has no value"),
(vec!["cluster:a\nb"], "not one line of text"),
(vec!["cluster:$FILE:/etc/cluster"], "starts with $"),
(vec!["cluster:$CLUSTER"], "starts with $"),
(vec!["cluster:a", "cluster:b"], "\"cluster\" is there twice"),
] {
let error = parse(&entries).unwrap_err();
assert_eq!(true, error.contains(reason), "{entries:?}: {error}");
}
let long = format!("cluster:{}", "a".repeat(129));
assert_eq!(true, parse(&[&long]).is_err());
let key = format!("{}:a", "k".repeat(33));
assert_eq!(true, parse(&[&key]).is_err());
let many: Vec<String> = (0..17).map(|i| format!("k{i}:v")).collect();
let many: Vec<&str> = many.iter().map(String::as_str).collect();
assert_eq!(true, parse(&many).unwrap_err().contains("the most is 16"));
}
}