1//! Handle - AT Protocol Handle Identifier
2//!
3//! handles are domain-name based identifiers for accounts.
4//! format: <segment>.<segment>...<tld>
5//!
6//! validation rules:
7//! - max 253 characters
8//! - ASCII only (a-z, 0-9, hyphen)
9//! - 2+ segments separated by dots
10//! - each segment: 1-63 chars, no leading/trailing hyphens
11//! - final segment (TLD) cannot start with a digit
12//! - case-insensitive, normalize to lowercase
13//!
14//! see: https://atproto.com/specs/handle
15
16const std = @import("std");
17
18pub const Handle = struct {
19 /// the handle string (borrowed, not owned)
20 raw: []const u8,
21
22 pub const max_length = 253;
23
24 /// parse a handle string. returns null if invalid.
25 pub fn parse(s: []const u8) ?Handle {
26 if (!isValid(s)) return null;
27 return .{ .raw = s };
28 }
29
30 /// validate a handle string without allocating
31 pub fn isValid(s: []const u8) bool {
32 // length check
33 if (s.len == 0 or s.len > max_length) return false;
34
35 // must be ascii
36 for (s) |c| {
37 if (c > 127) return false;
38 }
39
40 var segment_count: usize = 0;
41 var segment_start: usize = 0;
42 var last_segment_start: usize = 0;
43
44 for (s, 0..) |c, i| {
45 if (c == '.') {
46 const segment = s[segment_start..i];
47 if (!isValidSegment(segment)) return false;
48 segment_count += 1;
49 last_segment_start = i + 1;
50 segment_start = i + 1;
51 }
52 }
53
54 // validate final segment (TLD)
55 const tld = s[last_segment_start..];
56 if (!isValidSegment(tld)) return false;
57 if (!isValidTld(tld)) return false;
58 segment_count += 1;
59
60 // must have at least 2 segments
61 return segment_count >= 2;
62 }
63
64 /// get the handle string
65 pub fn str(self: Handle) []const u8 {
66 return self.raw;
67 }
68
69 fn isValidSegment(seg: []const u8) bool {
70 // 1-63 characters
71 if (seg.len == 0 or seg.len > 63) return false;
72
73 // cannot start or end with hyphen
74 if (seg[0] == '-' or seg[seg.len - 1] == '-') return false;
75
76 // only alphanumeric and hyphen
77 for (seg) |c| {
78 const valid = (c >= 'a' and c <= 'z') or
79 (c >= 'A' and c <= 'Z') or
80 (c >= '0' and c <= '9') or
81 c == '-';
82 if (!valid) return false;
83 }
84
85 return true;
86 }
87
88 fn isValidTld(tld: []const u8) bool {
89 if (tld.len == 0) return false;
90 // TLD cannot start with a digit
91 const first = tld[0];
92 return (first >= 'a' and first <= 'z') or (first >= 'A' and first <= 'Z');
93 }
94};
95
96// === tests from atproto.com/specs/handle ===
97
98test "valid: simple handle" {
99 try std.testing.expect(Handle.parse("jay.bsky.social") != null);
100 try std.testing.expect(Handle.parse("alice.example.com") != null);
101}
102
103test "valid: two segments" {
104 try std.testing.expect(Handle.parse("example.com") != null);
105 try std.testing.expect(Handle.parse("test.org") != null);
106}
107
108test "valid: many segments" {
109 try std.testing.expect(Handle.parse("a.b.c.d.e.f") != null);
110}
111
112test "valid: with hyphens" {
113 try std.testing.expect(Handle.parse("my-name.example.com") != null);
114 try std.testing.expect(Handle.parse("test.my-domain.org") != null);
115}
116
117test "valid: with numbers" {
118 try std.testing.expect(Handle.parse("user123.example.com") != null);
119 try std.testing.expect(Handle.parse("123user.example.com") != null);
120}
121
122test "valid: uppercase (allowed, normalize to lowercase)" {
123 try std.testing.expect(Handle.parse("LOUD.example.com") != null);
124 try std.testing.expect(Handle.parse("Jay.Bsky.Social") != null);
125}
126
127test "invalid: single segment" {
128 try std.testing.expect(Handle.parse("example") == null);
129 try std.testing.expect(Handle.parse("localhost") == null);
130}
131
132test "invalid: TLD starts with digit" {
133 try std.testing.expect(Handle.parse("john.0") == null);
134 try std.testing.expect(Handle.parse("test.123") == null);
135}
136
137test "invalid: segment starts with hyphen" {
138 try std.testing.expect(Handle.parse("-test.example.com") == null);
139 try std.testing.expect(Handle.parse("test.-example.com") == null);
140}
141
142test "invalid: segment ends with hyphen" {
143 try std.testing.expect(Handle.parse("test-.example.com") == null);
144 try std.testing.expect(Handle.parse("test.example-.com") == null);
145}
146
147test "invalid: empty segment" {
148 try std.testing.expect(Handle.parse(".example.com") == null);
149 try std.testing.expect(Handle.parse("test..com") == null);
150 try std.testing.expect(Handle.parse("test.example.") == null);
151}
152
153test "invalid: trailing dot" {
154 try std.testing.expect(Handle.parse("example.com.") == null);
155}
156
157test "invalid: invalid characters" {
158 try std.testing.expect(Handle.parse("test_name.example.com") == null);
159 try std.testing.expect(Handle.parse("test@name.example.com") == null);
160 try std.testing.expect(Handle.parse("test name.example.com") == null);
161}
162
163test "invalid: non-ascii" {
164 try std.testing.expect(Handle.parse("tëst.example.com") == null);
165}
166
167test "invalid: too long" {
168 // create a handle longer than 253 chars
169 var buf: [300]u8 = undefined;
170 @memset(&buf, 'a');
171 buf[100] = '.';
172 buf[200] = '.';
173 @memcpy(buf[201..204], "com");
174 try std.testing.expect(Handle.parse(buf[0..254]) == null);
175}
176
177test "invalid: segment too long" {
178 // segment > 63 chars
179 var buf: [100]u8 = undefined;
180 @memset(&buf, 'a');
181 buf[64] = '.';
182 @memcpy(buf[65..68], "com");
183 try std.testing.expect(Handle.parse(buf[0..68]) == null);
184}