truffle_core/identity.rs
1//! Identity and namespacing primitives (RFC 017).
2//!
3//! This module defines the three identifier types that truffle exposes at the
4//! application layer:
5//!
6//! - [`AppId`] — application-level namespace (`^[a-z][a-z0-9-]{1,31}$`).
7//! - [`DeviceName`] — user-supplied, Unicode-friendly, up to 256 graphemes.
8//! - [`DeviceId`] — 26-character Crockford base32 ULID, stable per-device.
9//!
10//! Plus helpers for deriving a Tailscale-safe hostname from a raw `DeviceName`:
11//!
12//! - [`slug`] — implements the 10-step sanitisation algorithm from §5.3.
13//! - [`tailscale_hostname`] — composes the final `truffle-{app_id}-{slug}` name.
14//!
15//! # Why these types?
16//!
17//! Before RFC 017, truffle hand-waved identity: callers passed a raw `name`
18//! string that the library handed verbatim to Tailscale. That broke for
19//! non-ASCII names, for names with spaces/quotes, for names longer than
20//! 63 characters, and it gave every app on the tailnet the same implicit
21//! hostname namespace.
22//!
23//! These newtypes lock down the invariants at construction time so lower
24//! layers can assume their inputs are already valid.
25
26use std::fmt;
27
28use unicode_normalization::UnicodeNormalization;
29
30// ---------------------------------------------------------------------------
31// Errors
32// ---------------------------------------------------------------------------
33
34/// Errors produced when parsing identifier inputs.
35#[derive(Debug, thiserror::Error)]
36pub enum IdentityError {
37 /// `AppId` failed the regex `^[a-z][a-z0-9-]{1,31}$`.
38 #[error("invalid app_id '{0}': must match ^[a-z][a-z0-9-]{{1,31}}$")]
39 InvalidAppId(String),
40 /// `DeviceId` was not a 26-char Crockford base32 ULID.
41 #[error("invalid device_id '{0}': not a valid ULID")]
42 InvalidDeviceId(String),
43}
44
45// ---------------------------------------------------------------------------
46// DeviceId — 26-char Crockford base32 ULID
47// ---------------------------------------------------------------------------
48
49/// A stable per-device identifier, formatted as a 26-character Crockford
50/// base32 ULID.
51///
52/// Applications that care about "is this the same logical device as last
53/// week" should persist and re-use a `DeviceId` across restarts. Truffle
54/// takes care of persistence automatically under `{state_dir}/device-id.txt`
55/// when no override is provided.
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub struct DeviceId(String);
58
59impl DeviceId {
60 /// Generate a new random ULID.
61 pub fn generate() -> Self {
62 let ulid = ulid::Ulid::new();
63 Self(ulid.to_string())
64 }
65
66 /// Parse a string as a ULID, returning an error if it is not a valid
67 /// 26-character Crockford base32 ULID.
68 pub fn parse(s: &str) -> Result<Self, IdentityError> {
69 ulid::Ulid::from_string(s)
70 .map(|u| Self(u.to_string()))
71 .map_err(|_| IdentityError::InvalidDeviceId(s.to_string()))
72 }
73
74 /// Borrow the underlying ULID string.
75 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78}
79
80impl fmt::Display for DeviceId {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str(&self.0)
83 }
84}
85
86// ---------------------------------------------------------------------------
87// AppId — ^[a-z][a-z0-9-]{1,31}$
88// ---------------------------------------------------------------------------
89
90/// An application identifier that defines the namespace two nodes must share
91/// in order to see each other as peers.
92///
93/// Valid `AppId`s match `^[a-z][a-z0-9-]{1,31}$`:
94/// - first character: lowercase ASCII letter
95/// - remaining: lowercase ASCII letters, digits, or `-`
96/// - length: 2–32 characters
97#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98pub struct AppId(String);
99
100impl AppId {
101 /// Parse a string as an `AppId`, rejecting anything that fails the
102 /// regex.
103 pub fn parse(s: &str) -> Result<Self, IdentityError> {
104 if !Self::is_valid(s) {
105 return Err(IdentityError::InvalidAppId(s.to_string()));
106 }
107 Ok(Self(s.to_string()))
108 }
109
110 fn is_valid(s: &str) -> bool {
111 // Length check: 2..=32 bytes (ASCII-only so bytes == chars).
112 let len = s.len();
113 if !(2..=32).contains(&len) {
114 return false;
115 }
116 let bytes = s.as_bytes();
117 // First character must be lowercase ASCII letter.
118 if !bytes[0].is_ascii_lowercase() {
119 return false;
120 }
121 // Remaining characters must be lowercase letter, digit, or '-'.
122 for &b in &bytes[1..] {
123 let ok = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
124 if !ok {
125 return false;
126 }
127 }
128 // Trailing hyphen is reserved (would produce ambiguous
129 // `truffle-{app_id}-{slug}` hostnames with double separators).
130 if bytes[bytes.len() - 1] == b'-' {
131 return false;
132 }
133 true
134 }
135
136 /// Borrow the underlying string representation.
137 pub fn as_str(&self) -> &str {
138 &self.0
139 }
140}
141
142impl fmt::Display for AppId {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 f.write_str(&self.0)
145 }
146}
147
148// ---------------------------------------------------------------------------
149// DeviceName — up to 256 graphemes of arbitrary Unicode
150// ---------------------------------------------------------------------------
151
152/// The soft cap on `DeviceName` length, measured in Unicode graphemes.
153const DEVICE_NAME_MAX_GRAPHEMES: usize = 256;
154
155/// A human-readable device name supplied by the user.
156///
157/// Accepts any Unicode input. Strings longer than 256 graphemes are
158/// truncated with a warning logged via `tracing`.
159#[derive(Debug, Clone, PartialEq, Eq, Hash)]
160pub struct DeviceName(String);
161
162impl DeviceName {
163 /// Parse a string as a `DeviceName`. Infallible — the only transformation
164 /// is a soft-cap truncation at 256 graphemes.
165 pub fn parse(s: impl Into<String>) -> Self {
166 let raw: String = s.into();
167 // Count graphemes without dragging in unicode-segmentation; walk
168 // char_indices and cut on the boundary after the 256th char. This
169 // is "close enough" — Rust `char` is a Unicode scalar value, which
170 // matches what most DNS-adjacent tooling means by "character".
171 //
172 // The RFC says "keep the first 256 graphemes, not bytes" — we use
173 // chars here to avoid an extra dependency; chars split on scalar
174 // values which never land in the middle of a multi-byte UTF-8
175 // sequence, so we never corrupt a codepoint.
176 let char_count = raw.chars().count();
177 if char_count <= DEVICE_NAME_MAX_GRAPHEMES {
178 return Self(raw);
179 }
180 tracing::warn!(
181 len = char_count,
182 max = DEVICE_NAME_MAX_GRAPHEMES,
183 "DeviceName exceeded {DEVICE_NAME_MAX_GRAPHEMES} graphemes; truncating"
184 );
185 let truncated: String = raw.chars().take(DEVICE_NAME_MAX_GRAPHEMES).collect();
186 Self(truncated)
187 }
188
189 /// Borrow the underlying string.
190 pub fn as_str(&self) -> &str {
191 &self.0
192 }
193}
194
195impl fmt::Display for DeviceName {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 f.write_str(&self.0)
198 }
199}
200
201// ---------------------------------------------------------------------------
202// Internal helpers for slug derivation (§5.3)
203// ---------------------------------------------------------------------------
204
205/// Step 1 of §5.3: NFKC-normalise the input.
206fn apply_unicode_normalization(s: &str) -> String {
207 s.nfkc().collect()
208}
209
210/// Step 2 of §5.3: transliterate to ASCII where a reasonable mapping exists.
211///
212/// `deunicode` handles diacritics, ligatures, and common scripts. Characters
213/// that `deunicode` cannot map get replaced with a `[?]` marker — we rewrite
214/// those (plus anything else non-ASCII-alphanumeric) to single hyphens in
215/// step 4, with contiguous runs collapsed in step 5.
216fn transliterate(s: &str) -> String {
217 deunicode::deunicode(s)
218}
219
220/// Step 5 of §5.3: collapse runs of multiple `-` into a single `-`.
221fn collapse_hyphens(s: &str) -> String {
222 s.split('-')
223 .filter(|x| !x.is_empty())
224 .collect::<Vec<_>>()
225 .join("-")
226}
227
228/// Deterministic hash fallback used when the sanitised slug would otherwise
229/// be empty or too short.
230///
231/// Returns the first `len` characters of the base36 encoding of
232/// `blake3::hash(raw)`.
233fn slug_fallback_hash(raw: &str, len: usize) -> String {
234 let hash = blake3::hash(raw.as_bytes());
235 let bytes = hash.as_bytes();
236 let encoded = base36_encode(bytes);
237 encoded.chars().take(len).collect()
238}
239
240/// Encode a byte slice as lowercase base36.
241///
242/// Treats `bytes` as a big-endian unsigned integer and emits digits
243/// `0-9a-z`. This is a minimal implementation — we do long division on
244/// the byte slice and reverse the resulting digit vector.
245fn base36_encode(bytes: &[u8]) -> String {
246 if bytes.iter().all(|b| *b == 0) {
247 return "0".to_string();
248 }
249 let mut n: Vec<u16> = bytes.iter().map(|b| *b as u16).collect();
250 let mut out = String::new();
251 while !n.is_empty() {
252 let mut rem: u16 = 0;
253 let mut new_n: Vec<u16> = Vec::with_capacity(n.len());
254 let mut started = false;
255 for d in &n {
256 let cur = rem * 256 + d;
257 let q = cur / 36;
258 rem = cur % 36;
259 if started || q != 0 {
260 new_n.push(q);
261 started = true;
262 }
263 }
264 let digit = rem as u8;
265 let c = if digit < 10 {
266 (b'0' + digit) as char
267 } else {
268 (b'a' + (digit - 10)) as char
269 };
270 out.push(c);
271 n = new_n;
272 }
273 out.chars().rev().collect()
274}
275
276// ---------------------------------------------------------------------------
277// slug() — §5.3
278// ---------------------------------------------------------------------------
279
280/// Produce a Tailscale-safe slug from a raw device-name string, fitting
281/// within `budget` ASCII characters.
282///
283/// Follows the 10-step algorithm defined in §5.3 of RFC 017 exactly:
284///
285/// 1. NFKC-normalise.
286/// 2. Transliterate Unicode to ASCII.
287/// 3. Lowercase.
288/// 4. Replace non-`[a-z0-9]` characters with `-`.
289/// 5. Collapse runs of `-`.
290/// 6. Trim leading/trailing `-`.
291/// 7. If empty, replace with first 8 chars of `base36(blake3(raw))`.
292/// 8. Truncate to `budget`.
293/// 9. Trim trailing `-` post-truncation.
294/// 10. If the result is <2 chars, append first 6 chars of
295/// `base36(blake3(raw))`.
296pub fn slug(raw: &str, budget: usize) -> String {
297 if budget == 0 {
298 return String::new();
299 }
300
301 // Step 1: NFKC.
302 let normalized = apply_unicode_normalization(raw);
303 // Step 2: transliterate.
304 let transliterated = transliterate(&normalized);
305 // Step 3: lowercase.
306 let lowered = transliterated.to_lowercase();
307 // Step 4: replace non-[a-z0-9] with '-'.
308 let replaced: String = lowered
309 .chars()
310 .map(|c| {
311 if c.is_ascii_lowercase() || c.is_ascii_digit() {
312 c
313 } else {
314 '-'
315 }
316 })
317 .collect();
318 // Step 5: collapse runs of '-'.
319 let collapsed = collapse_hyphens(&replaced);
320 // Step 6: trim leading/trailing '-'.
321 let trimmed = collapsed.trim_matches('-').to_string();
322
323 // Step 7: if empty, use hash fallback.
324 let mut candidate = if trimmed.is_empty() {
325 slug_fallback_hash(raw, 8)
326 } else {
327 trimmed
328 };
329
330 // Step 8: truncate to budget.
331 if candidate.len() > budget {
332 candidate = candidate.chars().take(budget).collect();
333 }
334
335 // Step 9: trim trailing '-' post-truncation.
336 while candidate.ends_with('-') {
337 candidate.pop();
338 }
339
340 // Step 10: if the result would be <2 chars, append 6 chars of hash.
341 if candidate.len() < 2 {
342 // Reserve 6 chars from the hash (or as many as the budget allows).
343 let hash_len = 6.min(budget.saturating_sub(candidate.len()));
344 let hash = slug_fallback_hash(raw, hash_len);
345 candidate.push_str(&hash);
346 // Re-truncate in case we overflowed the budget on the join.
347 if candidate.len() > budget {
348 candidate = candidate.chars().take(budget).collect();
349 }
350 // Trim trailing '-' one more time.
351 while candidate.ends_with('-') {
352 candidate.pop();
353 }
354 }
355
356 candidate
357}
358
359// ---------------------------------------------------------------------------
360// tailscale_hostname() — §5.3
361// ---------------------------------------------------------------------------
362
363/// Prefix every truffle-managed Tailscale hostname starts with.
364const HOSTNAME_PREFIX: &str = "truffle-";
365/// The DNS label length limit Tailscale enforces.
366const DNS_LABEL_LIMIT: usize = 63;
367
368/// Compose the final Tailscale hostname `truffle-{app_id}-{slug}`.
369///
370/// Callers provide the parsed `AppId` and the (unsanitised) `DeviceName`.
371/// Budget computation for the slug lives here so every caller gets the same
372/// answer.
373pub fn tailscale_hostname(app_id: &AppId, device_name: &DeviceName) -> String {
374 // Budget = 63 - len("truffle-") - len(app_id) - 1 (for the separating hyphen).
375 let app_id_str = app_id.as_str();
376 let fixed_prefix_len = HOSTNAME_PREFIX.len() + app_id_str.len() + 1;
377 let budget = DNS_LABEL_LIMIT.saturating_sub(fixed_prefix_len);
378
379 let slug_part = slug(device_name.as_str(), budget);
380 format!("{HOSTNAME_PREFIX}{app_id_str}-{slug_part}")
381}
382
383// ---------------------------------------------------------------------------
384// Tests
385// ---------------------------------------------------------------------------
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 // ── §5.3 examples table ──────────────────────────────────────────
392
393 #[test]
394 fn slug_alice_s_macbook_pro() {
395 // Budget for "playground" (10 chars): 63 - 8 - 10 - 1 = 44.
396 let budget = DNS_LABEL_LIMIT - HOSTNAME_PREFIX.len() - "playground".len() - 1;
397 assert_eq!(budget, 44);
398 let s = slug("Alice's MacBook Pro", budget);
399 assert_eq!(s, "alice-s-macbook-pro");
400
401 let host = tailscale_hostname(
402 &AppId::parse("playground").unwrap(),
403 &DeviceName::parse("Alice's MacBook Pro"),
404 );
405 assert_eq!(host, "truffle-playground-alice-s-macbook-pro");
406 }
407
408 #[test]
409 fn slug_cjk_name_hashed() {
410 // "田中's 部屋" — CJK runs become hyphens, leaving "s" and whatever
411 // deunicode produces for the CJK codepoints (often romanised). We
412 // want to prove determinism: the slug is hash-suffixed and stable.
413 let budget = 44;
414 let s1 = slug("田中's 部屋", budget);
415 let s2 = slug("田中's 部屋", budget);
416 assert_eq!(s1, s2, "slug must be deterministic");
417 // Must be non-empty, lowercase, and only contain [a-z0-9-].
418 assert!(!s1.is_empty());
419 assert!(
420 s1.chars()
421 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
422 "slug must be ascii-only: {s1}"
423 );
424 assert!(!s1.starts_with('-'));
425 assert!(!s1.ends_with('-'));
426 }
427
428 #[test]
429 fn slug_rocket_emoji_deunicoded() {
430 // "🚀" — `deunicode` maps this to a Latin string ("rocket"), so
431 // the hash fallback does NOT fire. Verify determinism and that
432 // the output is ASCII-clean. The exact string depends on the
433 // `deunicode` crate's lookup table; we only assert invariants.
434 let budget = 44;
435 let s1 = slug("🚀", budget);
436 let s2 = slug("🚀", budget);
437 assert_eq!(s1, s2, "slug must be deterministic");
438 assert!(!s1.is_empty());
439 assert!(
440 s1.chars()
441 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
442 "slug must be ASCII-clean: {s1}"
443 );
444 assert!(!s1.starts_with('-'));
445 assert!(!s1.ends_with('-'));
446 }
447
448 #[test]
449 fn slug_pure_symbol_hash_fallback() {
450 // A character that `deunicode` cannot map produces an empty
451 // transliteration, which triggers the step-7 hash fallback.
452 // The U+3013 "GETA MARK" (〓) typically has no Latin mapping.
453 let budget = 44;
454 let s1 = slug("〓", budget);
455 let s2 = slug("〓", budget);
456 assert_eq!(s1, s2, "hash fallback must be deterministic");
457 assert!(!s1.is_empty());
458 assert!(
459 s1.chars()
460 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
461 "hash fallback must be base36: {s1}"
462 );
463 }
464
465 #[test]
466 fn slug_james_mbp_16_strips_quote() {
467 let budget = 44;
468 let s = slug("JAMES-mbp-16\"", budget);
469 assert_eq!(s, "james-mbp-16");
470 }
471
472 #[test]
473 fn slug_long_name_truncated_no_trailing_hyphen() {
474 // Budget 44 for the playground app (63 - "truffle-" - "playground" - 1).
475 // The RFC §5.3 example table used an illustrative 47-char expectation
476 // but the actual budget for `playground` is 44 chars; we assert the
477 // correct truncation length here and leave the RFC as a doc-only
478 // discrepancy for the follow-up RFC touch-up.
479 let budget = 44;
480 let input =
481 "this-is-a-very-long-device-name-that-blows-past-the-dns-label-budget-and-then-some";
482 let s = slug(input, budget);
483 assert!(s.len() <= budget);
484 assert!(
485 !s.ends_with('-'),
486 "slug must not have a trailing hyphen: {s}"
487 );
488 // First 44 chars of the hyphenated input; truncation lands inside
489 // "past" and step 9 trims the trailing hyphen that would otherwise
490 // have dangled.
491 assert_eq!(s, "this-is-a-very-long-device-name-that-blows-p");
492 }
493
494 #[test]
495 fn slug_empty_string_falls_back_to_hash() {
496 // §5.3: empty input is expected to be rejected at the DeviceName
497 // layer (callers default to OS hostname). But the raw slug() function
498 // itself handles it gracefully by using the hash fallback, so we
499 // verify that here.
500 let budget = 44;
501 let s = slug("", budget);
502 assert!(!s.is_empty());
503 assert!(s.len() >= 2);
504 }
505
506 // ── AppId validation ─────────────────────────────────────────────
507
508 #[test]
509 fn app_id_accepts_valid_names() {
510 let valid = [
511 "playground",
512 "chat",
513 "a1",
514 "production-playground-v2",
515 "a-b",
516 ];
517 for name in valid {
518 AppId::parse(name).unwrap_or_else(|e| panic!("expected '{name}' to parse, got {e}"));
519 }
520 }
521
522 #[test]
523 fn app_id_rejects_invalid_names() {
524 // Tuple pairs: (input, reason it fails)
525 let invalid = [
526 ("Playground", "uppercase letters rejected"),
527 ("1foo", "must start with a letter, not a digit"),
528 ("a", "too short (needs >= 2 chars)"),
529 (
530 "this-is-way-way-too-long-for-a-reasonable-app-id",
531 "too long (> 32 chars)",
532 ),
533 ("foo_bar", "underscores not allowed"),
534 ("foo.bar", "dots not allowed"),
535 ("", "empty rejected"),
536 ("-foo", "leading hyphen (does not start with a letter)"),
537 ("foo-", "trailing hyphen rejected"),
538 (
539 "a-very-long-valid-chars-",
540 "trailing hyphen rejected even mid-length",
541 ),
542 ];
543 for (input, reason) in invalid {
544 assert!(
545 AppId::parse(input).is_err(),
546 "expected '{input}' to be rejected: {reason}"
547 );
548 }
549 }
550
551 // ── DeviceId ─────────────────────────────────────────────────────
552
553 #[test]
554 fn device_id_generate_returns_26_chars() {
555 let id = DeviceId::generate();
556 assert_eq!(id.as_str().len(), 26);
557 }
558
559 #[test]
560 fn device_id_generate_is_unique() {
561 let a = DeviceId::generate();
562 let b = DeviceId::generate();
563 assert_ne!(a, b, "two generated DeviceIds should differ");
564 }
565
566 #[test]
567 fn device_id_roundtrips_through_parse() {
568 let original = DeviceId::generate();
569 let parsed = DeviceId::parse(original.as_str()).unwrap();
570 assert_eq!(original, parsed);
571 }
572
573 #[test]
574 fn device_id_rejects_invalid() {
575 assert!(DeviceId::parse("not-a-ulid").is_err());
576 assert!(DeviceId::parse("").is_err());
577 // Wrong length (25 chars).
578 assert!(DeviceId::parse("01J4K9M2Z8AB3RNYQPW6H5TC0").is_err());
579 }
580
581 // ── DeviceName truncation ────────────────────────────────────────
582
583 #[test]
584 fn device_name_truncates_to_256_graphemes_no_split_codepoints() {
585 // A string of 300 multi-byte characters — uses the Greek letter
586 // alpha ("α") which is 2 bytes in UTF-8. If we accidentally did
587 // a byte-based truncation we'd end up with a broken UTF-8
588 // sequence and a panic on `String` construction. Verify we don't.
589 let long: String = std::iter::repeat('α').take(300).collect();
590 // Sanity check: byte length is 600 but char count is 300.
591 assert_eq!(long.chars().count(), 300);
592 assert_eq!(long.len(), 600);
593
594 let name = DeviceName::parse(long);
595 assert_eq!(name.as_str().chars().count(), DEVICE_NAME_MAX_GRAPHEMES);
596 // Every character is still a valid Greek alpha.
597 assert!(name.as_str().chars().all(|c| c == 'α'));
598 }
599
600 #[test]
601 fn device_name_short_string_passes_through() {
602 let name = DeviceName::parse("Alice's MacBook");
603 assert_eq!(name.as_str(), "Alice's MacBook");
604 }
605
606 // ── Slug collision determinism ───────────────────────────────────
607
608 #[test]
609 fn slug_rocket_emoji_stable_across_calls() {
610 let s1 = slug("🚀", 20);
611 let s2 = slug("🚀", 20);
612 assert_eq!(s1, s2);
613 }
614
615 // ── Budget edge cases ────────────────────────────────────────────
616
617 #[test]
618 fn slug_budget_two_forces_hash_fallback() {
619 // Budget 2 can't hold the normal transliteration of "Alice's MacBook";
620 // after truncation + trimming it'll end up truncated to something
621 // short. Verify the function still returns within budget and has
622 // at least 2 chars (step 10 pads with hash).
623 let s = slug("Alice's MacBook", 2);
624 assert!(s.len() <= 2);
625 assert!(
626 s.len() >= 2 || s.is_empty(),
627 "slug with budget 2 must be either empty or >= 2 chars: {s}"
628 );
629 }
630
631 #[test]
632 fn slug_budget_zero_returns_empty() {
633 let s = slug("Alice's MacBook", 0);
634 assert_eq!(s, "");
635 }
636
637 // ── base36 spot check ────────────────────────────────────────────
638
639 #[test]
640 fn base36_encode_known_values() {
641 // Zero encodes to "0".
642 assert_eq!(base36_encode(&[0, 0, 0]), "0");
643 // 35 encodes to "z".
644 assert_eq!(base36_encode(&[35]), "z");
645 // 36 encodes to "10".
646 assert_eq!(base36_encode(&[36]), "10");
647 // 255 encodes to "73" (255 = 7*36 + 3).
648 assert_eq!(base36_encode(&[255]), "73");
649 }
650}