atproto utils for zig zat.dev
atproto sdk zig
at main 260 lines 9.5 kB view raw
1//! AT-URI Parser 2//! 3//! at-uris identify repositories and records in the atproto network. 4//! format: at://<authority>[/<collection>[/<rkey>]] 5//! 6//! validation rules: 7//! - max 8KB length 8//! - no trailing slashes 9//! - authority is either a DID or handle 10//! - collection (if present) must be a valid NSID 11//! - rkey (if present) must be a valid record key 12//! 13//! see: https://atproto.com/specs/at-uri-scheme 14 15const std = @import("std"); 16const Did = @import("did.zig").Did; 17const Handle = @import("handle.zig").Handle; 18const Nsid = @import("nsid.zig").Nsid; 19const Rkey = @import("rkey.zig").Rkey; 20 21pub const AtUri = struct { 22 /// the full uri string (borrowed, not owned) 23 raw: []const u8, 24 25 /// offset where authority ends (after "at://") 26 authority_end: usize, 27 28 /// offset where collection ends (0 if no collection) 29 collection_end: usize, 30 31 pub const max_length = 8 * 1024; 32 const prefix = "at://"; 33 34 /// parse an at-uri. returns null if invalid. 35 pub fn parse(s: []const u8) ?AtUri { 36 // length check 37 if (s.len < prefix.len or s.len > max_length) return null; 38 39 // must start with "at://" 40 if (!std.mem.startsWith(u8, s, prefix)) return null; 41 42 // reject forbidden characters anywhere after prefix 43 for (s) |c| { 44 if (c == ' ' or c == '#' or c == '?') return null; 45 } 46 47 // no trailing slash 48 if (s[s.len - 1] == '/') return null; 49 50 const after_prefix = s[prefix.len..]; 51 if (after_prefix.len == 0) return null; // empty authority 52 53 // find first slash (end of authority) 54 const authority_end_rel = std.mem.indexOfScalar(u8, after_prefix, '/'); 55 56 const auth_str = after_prefix[0 .. authority_end_rel orelse after_prefix.len]; 57 if (auth_str.len == 0) return null; 58 59 // authority must be a valid DID or handle 60 if (Did.parse(auth_str) == null and Handle.parse(auth_str) == null) return null; 61 62 if (authority_end_rel) |ae| { 63 const after_authority = after_prefix[ae + 1 ..]; 64 if (after_authority.len == 0) return null; // trailing slash after authority 65 66 // find second slash (end of collection) 67 const collection_end_rel = std.mem.indexOfScalar(u8, after_authority, '/'); 68 69 const coll_str = after_authority[0 .. collection_end_rel orelse after_authority.len]; 70 if (coll_str.len == 0) return null; // empty collection 71 72 // collection must be a valid NSID 73 if (Nsid.parse(coll_str) == null) return null; 74 75 if (collection_end_rel) |ce| { 76 const rkey_str = after_authority[ce + 1 ..]; 77 if (rkey_str.len == 0) return null; // trailing slash after collection 78 79 // rkey must be a valid record key 80 if (Rkey.parse(rkey_str) == null) return null; 81 82 return .{ 83 .raw = s, 84 .authority_end = prefix.len + ae, 85 .collection_end = prefix.len + ae + 1 + ce, 86 }; 87 } else { 88 // uri with authority + collection only 89 return .{ 90 .raw = s, 91 .authority_end = prefix.len + ae, 92 .collection_end = s.len, 93 }; 94 } 95 } else { 96 // authority only 97 return .{ 98 .raw = s, 99 .authority_end = s.len, 100 .collection_end = 0, 101 }; 102 } 103 } 104 105 /// the authority portion (DID or handle) 106 pub fn authority(self: AtUri) []const u8 { 107 return self.raw[prefix.len..self.authority_end]; 108 } 109 110 /// the collection portion, or null if not present 111 pub fn collection(self: AtUri) ?[]const u8 { 112 if (self.collection_end == 0) return null; 113 return self.raw[self.authority_end + 1 .. self.collection_end]; 114 } 115 116 /// the rkey portion, or null if not present 117 pub fn rkey(self: AtUri) ?[]const u8 { 118 if (self.collection_end == 0) return null; 119 if (self.collection_end >= self.raw.len) return null; 120 const r = self.raw[self.collection_end + 1 ..]; 121 if (r.len == 0) return null; 122 return r; 123 } 124 125 /// check if this uri has a collection component 126 pub fn hasCollection(self: AtUri) bool { 127 return self.collection_end != 0; 128 } 129 130 /// check if this uri has an rkey component 131 pub fn hasRkey(self: AtUri) bool { 132 return self.rkey() != null; 133 } 134 135 /// format a new at-uri into the provided buffer. 136 /// returns the slice of the buffer used, or null if buffer too small. 137 pub fn format( 138 buf: []u8, 139 authority_str: []const u8, 140 collection_str: ?[]const u8, 141 rkey_str: ?[]const u8, 142 ) ?[]const u8 { 143 var total_len = prefix.len + authority_str.len; 144 if (collection_str) |c| { 145 total_len += 1 + c.len; 146 if (rkey_str) |r| { 147 total_len += 1 + r.len; 148 } 149 } 150 151 if (buf.len < total_len) return null; 152 153 var pos: usize = 0; 154 155 @memcpy(buf[pos..][0..prefix.len], prefix); 156 pos += prefix.len; 157 158 @memcpy(buf[pos..][0..authority_str.len], authority_str); 159 pos += authority_str.len; 160 161 if (collection_str) |c| { 162 buf[pos] = '/'; 163 pos += 1; 164 @memcpy(buf[pos..][0..c.len], c); 165 pos += c.len; 166 167 if (rkey_str) |r| { 168 buf[pos] = '/'; 169 pos += 1; 170 @memcpy(buf[pos..][0..r.len], r); 171 pos += r.len; 172 } 173 } 174 175 return buf[0..pos]; 176 } 177}; 178 179// === tests from atproto.com/specs/at-uri-scheme === 180 181test "valid: full uri with did:plc" { 182 const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jxtb5w2hkt2m") orelse return error.InvalidUri; 183 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority()); 184 try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?); 185 try std.testing.expectEqualStrings("3jxtb5w2hkt2m", uri.rkey().?); 186} 187 188test "valid: full uri with did:web" { 189 const uri = AtUri.parse("at://did:web:example.com/app.bsky.actor.profile/self") orelse return error.InvalidUri; 190 try std.testing.expectEqualStrings("did:web:example.com", uri.authority()); 191 try std.testing.expectEqualStrings("app.bsky.actor.profile", uri.collection().?); 192 try std.testing.expectEqualStrings("self", uri.rkey().?); 193} 194 195test "valid: full uri with handle" { 196 const uri = AtUri.parse("at://alice.bsky.social/app.bsky.feed.post/abc123") orelse return error.InvalidUri; 197 try std.testing.expectEqualStrings("alice.bsky.social", uri.authority()); 198 try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?); 199 try std.testing.expectEqualStrings("abc123", uri.rkey().?); 200} 201 202test "valid: authority only" { 203 const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur") orelse return error.InvalidUri; 204 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority()); 205 try std.testing.expect(uri.collection() == null); 206 try std.testing.expect(uri.rkey() == null); 207 try std.testing.expect(!uri.hasCollection()); 208 try std.testing.expect(!uri.hasRkey()); 209} 210 211test "valid: authority and collection only" { 212 const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post") orelse return error.InvalidUri; 213 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority()); 214 try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?); 215 try std.testing.expect(uri.rkey() == null); 216 try std.testing.expect(uri.hasCollection()); 217 try std.testing.expect(!uri.hasRkey()); 218} 219 220test "invalid: missing prefix" { 221 try std.testing.expect(AtUri.parse("did:plc:xyz/app.bsky.feed.post/abc") == null); 222 try std.testing.expect(AtUri.parse("http://did:plc:xyz/collection/rkey") == null); 223} 224 225test "invalid: empty authority" { 226 try std.testing.expect(AtUri.parse("at://") == null); 227 try std.testing.expect(AtUri.parse("at:///collection/rkey") == null); 228} 229 230test "invalid: trailing slash" { 231 try std.testing.expect(AtUri.parse("at://did:plc:xyz/") == null); 232 try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/") == null); 233 try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/rkey/") == null); 234} 235 236test "invalid: empty collection" { 237 try std.testing.expect(AtUri.parse("at://did:plc:xyz//rkey") == null); 238} 239 240test "invalid: empty rkey" { 241 try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/") == null); 242} 243 244test "format: full uri" { 245 var buf: [256]u8 = undefined; 246 const result = AtUri.format(&buf, "did:plc:xyz", "app.bsky.feed.post", "abc123") orelse return error.BufferTooSmall; 247 try std.testing.expectEqualStrings("at://did:plc:xyz/app.bsky.feed.post/abc123", result); 248} 249 250test "format: authority only" { 251 var buf: [256]u8 = undefined; 252 const result = AtUri.format(&buf, "did:plc:xyz", null, null) orelse return error.BufferTooSmall; 253 try std.testing.expectEqualStrings("at://did:plc:xyz", result); 254} 255 256test "format: authority and collection" { 257 var buf: [256]u8 = undefined; 258 const result = AtUri.format(&buf, "did:plc:xyz", "app.bsky.feed.post", null) orelse return error.BufferTooSmall; 259 try std.testing.expectEqualStrings("at://did:plc:xyz/app.bsky.feed.post", result); 260}