atproto utils for zig
zat.dev
atproto
sdk
zig
1//! NSID - Namespaced Identifier
2//!
3//! nsids identify lexicon schemas and record types.
4//! format: <reversed-domain>.<name>
5//!
6//! validation rules:
7//! - max 317 characters
8//! - 3+ segments separated by dots
9//! - domain authority: reversed domain (lowercase + digits + hyphens)
10//! - name segment: letters and digits only, cannot start with digit
11//! - each segment: 1-63 characters
12//!
13//! examples:
14//! - app.bsky.feed.post
15//! - com.atproto.repo.createRecord
16//!
17//! see: https://atproto.com/specs/nsid
18
19const std = @import("std");
20
21pub const Nsid = struct {
22 /// the nsid string (borrowed, not owned)
23 raw: []const u8,
24
25 /// offset where the name segment starts
26 name_start: usize,
27
28 pub const max_length = 317;
29 pub const max_segment_length = 63;
30
31 /// parse an nsid string. returns null if invalid.
32 pub fn parse(s: []const u8) ?Nsid {
33 // length check
34 if (s.len == 0 or s.len > max_length) return null;
35
36 var segment_count: usize = 0;
37 var segment_start: usize = 0;
38 var last_dot: usize = 0;
39
40 for (s, 0..) |c, i| {
41 if (c == '.') {
42 const segment = s[segment_start..i];
43 // all segments except last must be valid domain segments
44 if (!isValidDomainSegment(segment, segment_count == 0)) return null;
45 segment_count += 1;
46 last_dot = i;
47 segment_start = i + 1;
48 }
49 }
50
51 // validate final segment (name)
52 const name_seg = s[segment_start..];
53 if (!isValidNameSegment(name_seg)) return null;
54 segment_count += 1;
55
56 // must have at least 3 segments
57 if (segment_count < 3) return null;
58
59 return .{
60 .raw = s,
61 .name_start = last_dot + 1,
62 };
63 }
64
65 /// the full nsid string
66 pub fn str(self: Nsid) []const u8 {
67 return self.raw;
68 }
69
70 /// the domain authority portion (reversed domain)
71 pub fn authority(self: Nsid) []const u8 {
72 return self.raw[0 .. self.name_start - 1];
73 }
74
75 /// the name segment
76 pub fn name(self: Nsid) []const u8 {
77 return self.raw[self.name_start..];
78 }
79
80 fn isValidDomainSegment(seg: []const u8, is_first: bool) bool {
81 // 1-63 characters
82 if (seg.len == 0 or seg.len > max_segment_length) return false;
83
84 // cannot start or end with hyphen
85 if (seg[0] == '-' or seg[seg.len - 1] == '-') return false;
86
87 // first segment (TLD) must start with a letter
88 if (is_first and !(seg[0] >= 'a' and seg[0] <= 'z')) return false;
89
90 // lowercase letters, digits, and hyphens only
91 for (seg) |c| {
92 const valid = (c >= 'a' and c <= 'z') or
93 (c >= '0' and c <= '9') or
94 c == '-';
95 if (!valid) return false;
96 }
97
98 return true;
99 }
100
101 fn isValidNameSegment(seg: []const u8) bool {
102 // 1-63 characters
103 if (seg.len == 0 or seg.len > max_segment_length) return false;
104
105 // cannot start with digit
106 const first = seg[0];
107 if (first >= '0' and first <= '9') return false;
108
109 // letters and digits only (no hyphens in name)
110 for (seg) |c| {
111 const valid = (c >= 'a' and c <= 'z') or
112 (c >= 'A' and c <= 'Z') or
113 (c >= '0' and c <= '9');
114 if (!valid) return false;
115 }
116
117 return true;
118 }
119};
120
121// === tests from atproto.com/specs/nsid ===
122
123test "valid: common nsids" {
124 const nsid1 = Nsid.parse("app.bsky.feed.post") orelse return error.InvalidNsid;
125 try std.testing.expectEqualStrings("app.bsky.feed", nsid1.authority());
126 try std.testing.expectEqualStrings("post", nsid1.name());
127
128 const nsid2 = Nsid.parse("com.atproto.repo.createRecord") orelse return error.InvalidNsid;
129 try std.testing.expectEqualStrings("com.atproto.repo", nsid2.authority());
130 try std.testing.expectEqualStrings("createRecord", nsid2.name());
131}
132
133test "valid: minimum 3 segments" {
134 try std.testing.expect(Nsid.parse("a.b.c") != null);
135 try std.testing.expect(Nsid.parse("com.example.thing") != null);
136}
137
138test "valid: many segments" {
139 try std.testing.expect(Nsid.parse("net.users.bob.ping") != null);
140 try std.testing.expect(Nsid.parse("a.b.c.d.e.f") != null);
141}
142
143test "valid: name with numbers" {
144 try std.testing.expect(Nsid.parse("com.example.thing2") != null);
145 try std.testing.expect(Nsid.parse("app.bsky.feed.getPost1") != null);
146}
147
148test "valid: mixed case in name" {
149 try std.testing.expect(Nsid.parse("com.example.fooBar") != null);
150 try std.testing.expect(Nsid.parse("com.example.FooBar") != null);
151}
152
153test "invalid: only 2 segments" {
154 try std.testing.expect(Nsid.parse("com.example") == null);
155 try std.testing.expect(Nsid.parse("a.b") == null);
156}
157
158test "invalid: name starts with digit" {
159 try std.testing.expect(Nsid.parse("com.example.3") == null);
160 try std.testing.expect(Nsid.parse("com.example.3thing") == null);
161}
162
163test "invalid: name contains hyphen" {
164 try std.testing.expect(Nsid.parse("com.example.foo-bar") == null);
165}
166
167test "invalid: domain segment uppercase" {
168 try std.testing.expect(Nsid.parse("COM.example.thing") == null);
169 try std.testing.expect(Nsid.parse("com.EXAMPLE.thing") == null);
170}
171
172test "invalid: empty segment" {
173 try std.testing.expect(Nsid.parse(".example.thing") == null);
174 try std.testing.expect(Nsid.parse("com..thing") == null);
175 try std.testing.expect(Nsid.parse("com.example.") == null);
176}
177
178test "invalid: segment starts with hyphen" {
179 try std.testing.expect(Nsid.parse("-com.example.thing") == null);
180 try std.testing.expect(Nsid.parse("com.-example.thing") == null);
181}
182
183test "invalid: segment ends with hyphen" {
184 try std.testing.expect(Nsid.parse("com-.example.thing") == null);
185 try std.testing.expect(Nsid.parse("com.example-.thing") == null);
186}
187
188test "invalid: non-ascii" {
189 // this would be "com.exa💩ple.thing" but we just use a byte > 127
190 var buf = "com.example.thing".*;
191 buf[5] = 200; // non-ascii byte
192 try std.testing.expect(Nsid.parse(&buf) == null);
193}
194
195test "invalid: special characters" {
196 try std.testing.expect(Nsid.parse("com.example.thing!") == null);
197 try std.testing.expect(Nsid.parse("com.example.thing@") == null);
198 try std.testing.expect(Nsid.parse("com.example.thing*") == null);
199}