atproto utils for zig zat.dev
atproto sdk zig
at main 218 lines 6.8 kB view raw
1//! Handle Resolver - resolve handles to DIDs 2//! 3//! resolves AT Protocol handles via HTTP: 4//! https://{handle}/.well-known/atproto-did 5//! 6//! note: DNS TXT resolution (_atproto.{handle}) not yet implemented 7//! as zig std doesn't provide TXT record lookup. 8//! 9//! see: https://atproto.com/specs/handle 10 11const std = @import("std"); 12const Handle = @import("handle.zig").Handle; 13const Did = @import("did.zig").Did; 14const HttpTransport = @import("transport.zig").HttpTransport; 15 16pub const HandleResolver = struct { 17 allocator: std.mem.Allocator, 18 transport: HttpTransport, 19 doh_endpoint: []const u8, 20 21 pub fn init(allocator: std.mem.Allocator) HandleResolver { 22 return .{ 23 .allocator = allocator, 24 .transport = HttpTransport.init(allocator), 25 .doh_endpoint = "https://cloudflare-dns.com/dns-query", 26 }; 27 } 28 29 pub fn deinit(self: *HandleResolver) void { 30 self.transport.deinit(); 31 } 32 33 /// resolve a handle to a DID via HTTP well-known 34 pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 { 35 if (self.resolveHttp(handle)) |did| { 36 return did; 37 } else |_| { 38 return try self.resolveDns(handle); 39 } 40 } 41 42 /// resolve via HTTP at https://{handle}/.well-known/atproto-did 43 fn resolveHttp(self: *HandleResolver, handle: Handle) ![]const u8 { 44 const url = try std.fmt.allocPrint( 45 self.allocator, 46 "https://{s}/.well-known/atproto-did", 47 .{handle.str()}, 48 ); 49 defer self.allocator.free(url); 50 51 const result = self.transport.fetch(.{ .url = url }) catch return error.HttpResolutionFailed; 52 defer self.allocator.free(result.body); 53 54 if (result.status != .ok) { 55 return error.HttpResolutionFailed; 56 } 57 58 // response body should be the DID as plain text 59 const did_str = std.mem.trim(u8, result.body, &std.ascii.whitespace); 60 61 // validate it's a proper DID 62 if (Did.parse(did_str) == null) { 63 return error.InvalidDidInResponse; 64 } 65 66 return try self.allocator.dupe(u8, did_str); 67 } 68 69 /// resolve via DoH default: https://cloudflare-dns.com/dns-query 70 pub fn resolveDns(self: *HandleResolver, handle: Handle) ![]const u8 { 71 const dns_name = try std.fmt.allocPrint( 72 self.allocator, 73 "_atproto.{s}", 74 .{handle.str()}, 75 ); 76 defer self.allocator.free(dns_name); 77 78 const url = try std.fmt.allocPrint( 79 self.allocator, 80 "{s}?name={s}&type=TXT", 81 .{ self.doh_endpoint, dns_name }, 82 ); 83 defer self.allocator.free(url); 84 85 const result = self.transport.fetch(.{ 86 .url = url, 87 .accept = "application/dns-json", 88 }) catch return error.DnsResolutionFailed; 89 defer self.allocator.free(result.body); 90 91 if (result.status != .ok) { 92 return error.DnsResolutionFailed; 93 } 94 95 const parsed = std.json.parseFromSlice( 96 DnsResponse, 97 self.allocator, 98 result.body, 99 .{}, 100 ) catch return error.InvalidDnsResponse; 101 defer parsed.deinit(); 102 103 const dns_response = parsed.value; 104 if (dns_response.Answer == null or dns_response.Answer.?.len == 0) { 105 return error.NoDnsRecordsFound; 106 } 107 108 for (dns_response.Answer.?) |answer| { 109 const data = answer.data orelse continue; 110 const did_str = extractDidFromTxt(data) orelse continue; 111 112 if (Did.parse(did_str) != null) { 113 return try self.allocator.dupe(u8, did_str); 114 } 115 } 116 117 return error.NoValidDidFound; 118 } 119}; 120 121fn extractDidFromTxt(txt_data: []const u8) ?[]const u8 { 122 var data = txt_data; 123 if (data.len >= 2 and data[0] == '"' and data[data.len - 1] == '"') { 124 data = data[1 .. data.len - 1]; 125 } 126 127 const prefix = "did="; 128 if (std.mem.startsWith(u8, data, prefix)) { 129 return data[prefix.len..]; 130 } 131 132 return null; 133} 134 135const DnsResponse = struct { 136 Status: i32, 137 TC: bool, 138 RD: bool, 139 RA: bool, 140 AD: bool, 141 CD: bool, 142 Question: ?[]Question = null, 143 Answer: ?[]Answer = null, 144}; 145 146const Question = struct { 147 name: []const u8, 148 type: i32, 149}; 150 151const Answer = struct { 152 name: []const u8, 153 type: i32, 154 TTL: i32, 155 data: ?[]const u8 = null, 156}; 157 158// === integration tests === 159// these actually hit the network - run with: zig test src/internal/handle_resolver.zig 160 161test "resolve handle (http) - integration" { 162 // use arena for http client internals that may leak 163 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 164 defer arena.deinit(); 165 166 var resolver = HandleResolver.init(arena.allocator()); 167 defer resolver.deinit(); 168 169 // resolve a known handle that has .well-known/atproto-did 170 const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle; 171 const did = resolver.resolveHttp(handle) catch |err| { 172 // network errors are ok in CI without network access 173 std.debug.print("network error (expected in some CI): {}\n", .{err}); 174 return; 175 }; 176 177 // should be a valid did:plc 178 try std.testing.expect(Did.parse(did) != null); 179 try std.testing.expect(std.mem.startsWith(u8, did, "did:plc:")); 180} 181 182test "resolve handle (dns over http) - integration" { 183 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 184 defer arena.deinit(); 185 186 var resolver = HandleResolver.init(arena.allocator()); 187 defer resolver.deinit(); 188 189 const handle = Handle.parse("seiso.moe") orelse return error.InvalidHandle; 190 const did = resolver.resolveDns(handle) catch |err| { 191 // network errors are ok in CI without network access 192 std.debug.print("network error (expected in some CI): {}\n", .{err}); 193 return; 194 }; 195 196 // should be a valid DID 197 try std.testing.expect(Did.parse(did) != null); 198 try std.testing.expect(std.mem.startsWith(u8, did, "did:")); 199} 200 201test "resolve handle - integration" { 202 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 203 defer arena.deinit(); 204 205 var resolver = HandleResolver.init(arena.allocator()); 206 defer resolver.deinit(); 207 208 const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle; 209 const did = resolver.resolve(handle) catch |err| { 210 // network errors are ok in CI without network access 211 std.debug.print("network error (expected in some CI): {}\n", .{err}); 212 return; 213 }; 214 215 // should be a valid DID 216 try std.testing.expect(Did.parse(did) != null); 217 try std.testing.expect(std.mem.startsWith(u8, did, "did:")); 218}