use std::fmt;
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
#[error("invalid app_id '{0}': must match ^[a-z][a-z0-9-]{{1,31}}$")]
InvalidAppId(String),
#[error("invalid device_id '{0}': not a valid ULID")]
InvalidDeviceId(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DeviceId(String);
impl DeviceId {
pub fn generate() -> Self {
let ulid = ulid::Ulid::new();
Self(ulid.to_string())
}
pub fn parse(s: &str) -> Result<Self, IdentityError> {
ulid::Ulid::from_string(s)
.map(|u| Self(u.to_string()))
.map_err(|_| IdentityError::InvalidDeviceId(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DeviceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppId(String);
impl AppId {
pub fn parse(s: &str) -> Result<Self, IdentityError> {
if !Self::is_valid(s) {
return Err(IdentityError::InvalidAppId(s.to_string()));
}
Ok(Self(s.to_string()))
}
fn is_valid(s: &str) -> bool {
let len = s.len();
if !(2..=32).contains(&len) {
return false;
}
let bytes = s.as_bytes();
if !bytes[0].is_ascii_lowercase() {
return false;
}
for &b in &bytes[1..] {
let ok = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
if !ok {
return false;
}
}
if bytes[bytes.len() - 1] == b'-' {
return false;
}
true
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AppId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
const DEVICE_NAME_MAX_GRAPHEMES: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DeviceName(String);
impl DeviceName {
pub fn parse(s: impl Into<String>) -> Self {
let raw: String = s.into();
let char_count = raw.chars().count();
if char_count <= DEVICE_NAME_MAX_GRAPHEMES {
return Self(raw);
}
tracing::warn!(
len = char_count,
max = DEVICE_NAME_MAX_GRAPHEMES,
"DeviceName exceeded {DEVICE_NAME_MAX_GRAPHEMES} graphemes; truncating"
);
let truncated: String = raw.chars().take(DEVICE_NAME_MAX_GRAPHEMES).collect();
Self(truncated)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DeviceName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
fn apply_unicode_normalization(s: &str) -> String {
s.nfkc().collect()
}
fn transliterate(s: &str) -> String {
deunicode::deunicode(s)
}
fn collapse_hyphens(s: &str) -> String {
s.split('-')
.filter(|x| !x.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn slug_fallback_hash(raw: &str, len: usize) -> String {
let hash = blake3::hash(raw.as_bytes());
let bytes = hash.as_bytes();
let encoded = base36_encode(bytes);
encoded.chars().take(len).collect()
}
fn base36_encode(bytes: &[u8]) -> String {
if bytes.iter().all(|b| *b == 0) {
return "0".to_string();
}
let mut n: Vec<u16> = bytes.iter().map(|b| *b as u16).collect();
let mut out = String::new();
while !n.is_empty() {
let mut rem: u16 = 0;
let mut new_n: Vec<u16> = Vec::with_capacity(n.len());
let mut started = false;
for d in &n {
let cur = rem * 256 + d;
let q = cur / 36;
rem = cur % 36;
if started || q != 0 {
new_n.push(q);
started = true;
}
}
let digit = rem as u8;
let c = if digit < 10 {
(b'0' + digit) as char
} else {
(b'a' + (digit - 10)) as char
};
out.push(c);
n = new_n;
}
out.chars().rev().collect()
}
pub fn slug(raw: &str, budget: usize) -> String {
if budget == 0 {
return String::new();
}
let normalized = apply_unicode_normalization(raw);
let transliterated = transliterate(&normalized);
let lowered = transliterated.to_lowercase();
let replaced: String = lowered
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() {
c
} else {
'-'
}
})
.collect();
let collapsed = collapse_hyphens(&replaced);
let trimmed = collapsed.trim_matches('-').to_string();
let mut candidate = if trimmed.is_empty() {
slug_fallback_hash(raw, 8)
} else {
trimmed
};
if candidate.len() > budget {
candidate = candidate.chars().take(budget).collect();
}
while candidate.ends_with('-') {
candidate.pop();
}
if candidate.len() < 2 {
let hash_len = 6.min(budget.saturating_sub(candidate.len()));
let hash = slug_fallback_hash(raw, hash_len);
candidate.push_str(&hash);
if candidate.len() > budget {
candidate = candidate.chars().take(budget).collect();
}
while candidate.ends_with('-') {
candidate.pop();
}
}
candidate
}
const HOSTNAME_PREFIX: &str = "truffle-";
const DNS_LABEL_LIMIT: usize = 63;
pub fn tailscale_hostname(app_id: &AppId, device_name: &DeviceName) -> String {
let app_id_str = app_id.as_str();
let fixed_prefix_len = HOSTNAME_PREFIX.len() + app_id_str.len() + 1;
let budget = DNS_LABEL_LIMIT.saturating_sub(fixed_prefix_len);
let slug_part = slug(device_name.as_str(), budget);
format!("{HOSTNAME_PREFIX}{app_id_str}-{slug_part}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_alice_s_macbook_pro() {
let budget = DNS_LABEL_LIMIT - HOSTNAME_PREFIX.len() - "playground".len() - 1;
assert_eq!(budget, 44);
let s = slug("Alice's MacBook Pro", budget);
assert_eq!(s, "alice-s-macbook-pro");
let host = tailscale_hostname(
&AppId::parse("playground").unwrap(),
&DeviceName::parse("Alice's MacBook Pro"),
);
assert_eq!(host, "truffle-playground-alice-s-macbook-pro");
}
#[test]
fn slug_cjk_name_hashed() {
let budget = 44;
let s1 = slug("田中's 部屋", budget);
let s2 = slug("田中's 部屋", budget);
assert_eq!(s1, s2, "slug must be deterministic");
assert!(!s1.is_empty());
assert!(
s1.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"slug must be ascii-only: {s1}"
);
assert!(!s1.starts_with('-'));
assert!(!s1.ends_with('-'));
}
#[test]
fn slug_rocket_emoji_deunicoded() {
let budget = 44;
let s1 = slug("🚀", budget);
let s2 = slug("🚀", budget);
assert_eq!(s1, s2, "slug must be deterministic");
assert!(!s1.is_empty());
assert!(
s1.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"slug must be ASCII-clean: {s1}"
);
assert!(!s1.starts_with('-'));
assert!(!s1.ends_with('-'));
}
#[test]
fn slug_pure_symbol_hash_fallback() {
let budget = 44;
let s1 = slug("〓", budget);
let s2 = slug("〓", budget);
assert_eq!(s1, s2, "hash fallback must be deterministic");
assert!(!s1.is_empty());
assert!(
s1.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
"hash fallback must be base36: {s1}"
);
}
#[test]
fn slug_james_mbp_16_strips_quote() {
let budget = 44;
let s = slug("JAMES-mbp-16\"", budget);
assert_eq!(s, "james-mbp-16");
}
#[test]
fn slug_long_name_truncated_no_trailing_hyphen() {
let budget = 44;
let input =
"this-is-a-very-long-device-name-that-blows-past-the-dns-label-budget-and-then-some";
let s = slug(input, budget);
assert!(s.len() <= budget);
assert!(
!s.ends_with('-'),
"slug must not have a trailing hyphen: {s}"
);
assert_eq!(s, "this-is-a-very-long-device-name-that-blows-p");
}
#[test]
fn slug_empty_string_falls_back_to_hash() {
let budget = 44;
let s = slug("", budget);
assert!(!s.is_empty());
assert!(s.len() >= 2);
}
#[test]
fn app_id_accepts_valid_names() {
let valid = [
"playground",
"chat",
"a1",
"production-playground-v2",
"a-b",
];
for name in valid {
AppId::parse(name).unwrap_or_else(|e| panic!("expected '{name}' to parse, got {e}"));
}
}
#[test]
fn app_id_rejects_invalid_names() {
let invalid = [
("Playground", "uppercase letters rejected"),
("1foo", "must start with a letter, not a digit"),
("a", "too short (needs >= 2 chars)"),
(
"this-is-way-way-too-long-for-a-reasonable-app-id",
"too long (> 32 chars)",
),
("foo_bar", "underscores not allowed"),
("foo.bar", "dots not allowed"),
("", "empty rejected"),
("-foo", "leading hyphen (does not start with a letter)"),
("foo-", "trailing hyphen rejected"),
(
"a-very-long-valid-chars-",
"trailing hyphen rejected even mid-length",
),
];
for (input, reason) in invalid {
assert!(
AppId::parse(input).is_err(),
"expected '{input}' to be rejected: {reason}"
);
}
}
#[test]
fn device_id_generate_returns_26_chars() {
let id = DeviceId::generate();
assert_eq!(id.as_str().len(), 26);
}
#[test]
fn device_id_generate_is_unique() {
let a = DeviceId::generate();
let b = DeviceId::generate();
assert_ne!(a, b, "two generated DeviceIds should differ");
}
#[test]
fn device_id_roundtrips_through_parse() {
let original = DeviceId::generate();
let parsed = DeviceId::parse(original.as_str()).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn device_id_rejects_invalid() {
assert!(DeviceId::parse("not-a-ulid").is_err());
assert!(DeviceId::parse("").is_err());
assert!(DeviceId::parse("01J4K9M2Z8AB3RNYQPW6H5TC0").is_err());
}
#[test]
fn device_name_truncates_to_256_graphemes_no_split_codepoints() {
let long: String = std::iter::repeat('α').take(300).collect();
assert_eq!(long.chars().count(), 300);
assert_eq!(long.len(), 600);
let name = DeviceName::parse(long);
assert_eq!(name.as_str().chars().count(), DEVICE_NAME_MAX_GRAPHEMES);
assert!(name.as_str().chars().all(|c| c == 'α'));
}
#[test]
fn device_name_short_string_passes_through() {
let name = DeviceName::parse("Alice's MacBook");
assert_eq!(name.as_str(), "Alice's MacBook");
}
#[test]
fn slug_rocket_emoji_stable_across_calls() {
let s1 = slug("🚀", 20);
let s2 = slug("🚀", 20);
assert_eq!(s1, s2);
}
#[test]
fn slug_budget_two_forces_hash_fallback() {
let s = slug("Alice's MacBook", 2);
assert!(s.len() <= 2);
assert!(
s.len() >= 2 || s.is_empty(),
"slug with budget 2 must be either empty or >= 2 chars: {s}"
);
}
#[test]
fn slug_budget_zero_returns_empty() {
let s = slug("Alice's MacBook", 0);
assert_eq!(s, "");
}
#[test]
fn base36_encode_known_values() {
assert_eq!(base36_encode(&[0, 0, 0]), "0");
assert_eq!(base36_encode(&[35]), "z");
assert_eq!(base36_encode(&[36]), "10");
assert_eq!(base36_encode(&[255]), "73");
}
}