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