use std::net::IpAddr;
use crate::grammar::is_valid_plain_chunk;
pub fn ip_slug(ip: IpAddr) -> String {
ip.to_string().replace(['.', ':'], "-")
}
pub fn ip_slug_str(text: &str) -> Option<String> {
text.parse::<IpAddr>().ok().map(ip_slug)
}
pub fn ulid_slug(id: &str) -> Option<String> {
fn crockford(b: u8) -> bool {
let b = b.to_ascii_uppercase();
b.is_ascii_digit() || (b.is_ascii_uppercase() && !matches!(b, b'I' | b'L' | b'O' | b'U'))
}
(id.len() == 26 && id.bytes().all(crockford)).then(|| id.to_ascii_lowercase())
}
const RESERVED_PREFIX: &str = "x-";
pub fn chunk_slug(value: &str) -> String {
if is_valid_plain_chunk(value) && !value.starts_with(RESERVED_PREFIX) {
return value.to_string();
}
let bytes = value.as_bytes();
let mut out = String::with_capacity(value.len() + 8);
out.push_str(RESERVED_PREFIX);
if bytes.is_empty() {
out.push_str("_x");
return out;
}
let last = bytes.len() - 1;
for (i, &b) in bytes.iter().enumerate() {
let literal =
b.is_ascii_lowercase() || b.is_ascii_digit() || ((b == b'.' || b == b'-') && i != last);
if literal {
out.push(b as char);
} else {
out.push_str(&format!("_x{b:02x}"));
}
}
out
}
#[must_use]
pub fn chunk_unslug(chunk: &str) -> Option<String> {
let Some(body) = chunk.strip_prefix(RESERVED_PREFIX) else {
return is_valid_plain_chunk(chunk).then(|| chunk.to_string());
};
let decoded = if body == "_x" {
String::new()
} else {
let bytes = body.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'_' {
let hex = bytes.get(i + 1..i + 4)?;
if hex[0] != b'x'
|| !hex[1..]
.iter()
.all(|h| matches!(h, b'0'..=b'9' | b'a'..=b'f'))
{
return None;
}
let hi = (hex[1] as char).to_digit(16)? as u8;
let lo = (hex[2] as char).to_digit(16)? as u8;
out.push((hi << 4) | lo);
i += 4;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()?
};
(chunk_slug(&decoded) == chunk).then_some(decoded)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn ipv4_always_slugged() {
assert_eq!(ip_slug_str("10.0.0.7").unwrap(), "10-0-0-7");
assert_eq!(ip_slug_str("93.184.216.34").unwrap(), "93-184-216-34");
}
#[test]
fn ipv6_rfc5952_canonical_before_slugging() {
let a = ip_slug_str("2001:db8::1").unwrap();
let b = ip_slug_str("2001:db8:0:0:0:0:0:1").unwrap();
let c = ip_slug_str("2001:DB8::1").unwrap();
assert_eq!(a, "2001-db8--1");
assert_eq!(a, b);
assert_eq!(a, c);
}
const CORPUS: &[&str] = &[
"foo@1.service",
"foo-1.service",
"getty@tty1.service",
"getty-tty1.service",
"a b",
"a_b",
"a-b",
"A",
"a",
"Ab",
"a.b",
".ab",
"ab.",
"café",
"unit@.service",
"_myns",
"e_myns",
"x_myns",
"myns_",
"_",
"x-foo",
"x_x5f_myns",
"x@b",
"@b",
"a_",
"a_x",
"",
"x-",
"x",
];
#[test]
fn legal_values_stay_literal() {
assert_eq!(chunk_slug("sshd.service"), "sshd.service");
assert_eq!(chunk_slug("cam0"), "cam0");
assert_eq!(chunk_slug("x_x5f_myns"), "x_x5f_myns");
assert_eq!(chunk_slug("a_x"), "a_x");
}
#[test]
fn the_reserved_prefix_is_escaped_not_passed_through() {
assert!(crate::grammar::is_valid_plain_chunk("x-foo"));
assert_eq!(chunk_slug("x-foo"), "x-x-foo");
assert_eq!(chunk_slug("x-1"), "x-x-1");
assert_eq!(chunk_slug("x"), "x");
assert_eq!(chunk_slug("x_1"), "x_1");
}
#[test]
fn slug_outputs_are_pinned() {
let table = [
("sshd.service", "sshd.service"),
("cam0", "cam0"),
("x_x5f_myns", "x_x5f_myns"),
("a_x", "a_x"),
("x-foo", "x-x-foo"),
("_myns", "x-_x5fmyns"),
("x@b", "x-x_x40b"),
("@b", "x-_x40b"),
("foo@1.service", "x-foo_x401.service"),
("has spaces", "x-has_x20spaces"),
("a_", "x-a_x5f"),
("_", "x-_x5f"),
(".ab", "x-.ab"),
("ab.", "x-ab_x2e"),
("A", "x-_x41"),
("ETH0", "x-_x45_x54_x480"),
("café", "x-caf_xc3_xa9"),
("", "x-_x"),
];
for (value, chunk) in table {
assert_eq!(chunk_slug(value), chunk, "slug of {value:?}");
}
}
#[test]
fn escape_is_injective() {
assert_ne!(chunk_slug("foo@1.service"), chunk_slug("foo-1.service"));
let slugs: Vec<String> = CORPUS.iter().map(|v| chunk_slug(v)).collect();
let unique: HashSet<&String> = slugs.iter().collect();
assert_eq!(unique.len(), CORPUS.len(), "collision in {slugs:?}");
for s in &slugs {
assert!(
crate::grammar::is_valid_plain_chunk(s),
"illegal slug {s:?}"
);
}
}
#[test]
fn unslug_round_trips_the_corpus() {
for v in CORPUS.iter().copied().chain(["日本", "\u{0}", "a\tb"]) {
let chunk = chunk_slug(v);
assert_eq!(
chunk_unslug(&chunk).as_deref(),
Some(v),
"round trip of {v:?} via {chunk:?}"
);
}
}
#[test]
fn unslug_refuses_malformed_and_non_canonical() {
for bad in [
"x-a_", "x-a_x4", "x-a_xzz", "x-a_X41", "x-a_x4A", "x-_xff", "x-abc", "x-", "x-_xa", "Foo", ] {
assert_eq!(chunk_unslug(bad), None, "{bad:?} must be refused");
}
}
#[test]
fn ulid_shapes_lowercase_and_others_refuse() {
let canonical = "01JGXQZ4YQK8V6TXW3M9F2A7CD";
assert_eq!(
ulid_slug(canonical).as_deref(),
Some("01jgxqz4yqk8v6txw3m9f2a7cd")
);
assert_eq!(
ulid_slug("01jgxqz4yqk8v6txw3m9f2a7cd").as_deref(),
Some("01jgxqz4yqk8v6txw3m9f2a7cd"),
"already-lowercase is the fixed point"
);
assert_eq!(ulid_slug("01HQXK8F9C2N4PZQ"), None, "16 chars");
assert_eq!(ulid_slug("01JGXQZ4YQK8V6TXW3M9F2A7CI"), None, "I excluded");
assert_eq!(ulid_slug("01jgxqz4yqk8v6txw3m9f2a7c."), None);
assert_eq!(ulid_slug(""), None);
}
#[test]
fn event_ids_are_ulid_slugs() {
let id = ulid_slug("01JGXQZ4YQK8V6TXW3M9F2A7CD").unwrap();
let key = crate::grammar::data_key(
&crate::grammar::Origin::Host(crate::origin::HostId::parse("h-3fa9c2d41b7e").unwrap()),
crate::grammar::Class::Events,
Some(&crate::grammar::Producer::new("netring").unwrap()),
&["capture", &id],
)
.unwrap();
assert_eq!(
key,
"v1/h-3fa9c2d41b7e/events/netring/capture/01jgxqz4yqk8v6txw3m9f2a7cd"
);
}
#[test]
fn v131_erratum_examples_are_the_rfcs() {
for (a, b) in [("_myns", "x_x5f_myns"), ("x@b", "@b")] {
let (sa, sb) = (chunk_slug(a), chunk_slug(b));
assert_ne!(sa, sb, "{a:?} and {b:?} must not share a chunk");
assert_eq!(chunk_unslug(&sa).as_deref(), Some(a));
assert_eq!(chunk_unslug(&sb).as_deref(), Some(b));
}
assert_eq!(chunk_slug("_myns"), "x-_x5fmyns");
assert_eq!(chunk_slug("x_x5f_myns"), "x_x5f_myns");
assert_eq!(chunk_slug("x@b"), "x-x_x40b");
assert_eq!(chunk_slug("@b"), "x-_x40b");
}
}