Skip to main content

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}