atproto utils for zig zat.dev
atproto sdk zig
at main 184 lines 5.6 kB view raw
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}