atproto utils for zig zat.dev
atproto sdk zig
Something went wrong. Try again.
Zig
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237//! Handle Resolver - resolve handles to DIDs//!//! resolves AT Protocol handles via HTTP://! https://{handle}/.well-known/atproto-did//!//! DNS TXT resolution uses DNS-over-HTTPS because Zig stdlib does not expose//! direct TXT lookup.//!//! see: https://atproto.com/specs/handle
const std = @import("std");const Handle = @import("../syntax/handle.zig").Handle;const Did = @import("../syntax/did.zig").Did;const HttpTransport = @import("../xrpc/transport.zig").HttpTransport;const network_safety = @import("network_safety.zig");
const max_handle_response_size = 8 * 1024;const max_dns_txt_response_size = 1 * 1024 * 1024;
pub const HandleResolver = struct { allocator: std.mem.Allocator, transport: HttpTransport, doh_endpoint: []const u8,
pub fn init(io: std.Io, allocator: std.mem.Allocator) HandleResolver { return .{ .allocator = allocator, .transport = HttpTransport.init(io, allocator), .doh_endpoint = "https://cloudflare-dns.com/dns-query", }; }
pub fn deinit(self: *HandleResolver) void { self.transport.deinit(); }
/// resolve a handle to a DID via HTTP well-known pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 { if (self.resolveHttp(handle)) |did| { return did; } else |err| switch (err) { error.UnsafeIdentityHost => return err, else => return try self.resolveDns(handle), } }
/// resolve via HTTP at https://{handle}/.well-known/atproto-did fn resolveHttp(self: *HandleResolver, handle: Handle) ![]const u8 { const url = try std.fmt.allocPrint( self.allocator, "https://{s}/.well-known/atproto-did", .{handle.str()}, ); defer self.allocator.free(url);
var checked_url = network_safety.resolveIdentityUrl( self.allocator, &self.transport, self.doh_endpoint, url, ) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.UnsafeIdentityHost => |e| return e, else => return error.HttpResolutionFailed, }; defer checked_url.deinit(self.allocator);
const result = self.transport.fetch(.{ .url = url, .max_response_size = max_handle_response_size, .redirect_behavior = .not_allowed, .resolved_connection = checked_url.resolvedConnection(), }) catch return error.HttpResolutionFailed; defer self.allocator.free(result.body);
if (result.status != .ok) { return error.HttpResolutionFailed; }
// response body should be the DID as plain text const did_str = std.mem.trim(u8, result.body, &std.ascii.whitespace);
// validate it's a proper DID if (Did.parse(did_str) == null) { return error.InvalidDidInResponse; }
return try self.allocator.dupe(u8, did_str); }
/// resolve via DoH default: https://cloudflare-dns.com/dns-query pub fn resolveDns(self: *HandleResolver, handle: Handle) ![]const u8 { const dns_name = try std.fmt.allocPrint( self.allocator, "_atproto.{s}", .{handle.str()}, ); defer self.allocator.free(dns_name);
const url = try std.fmt.allocPrint( self.allocator, "{s}?name={s}&type=TXT", .{ self.doh_endpoint, dns_name }, ); defer self.allocator.free(url);
const result = self.transport.fetch(.{ .url = url, .accept = "application/dns-json", .max_response_size = max_dns_txt_response_size, .redirect_behavior = .not_allowed, }) catch return error.DnsResolutionFailed; defer self.allocator.free(result.body);
if (result.status != .ok) { return error.DnsResolutionFailed; }
const parsed = std.json.parseFromSlice( DnsResponse, self.allocator, result.body, .{}, ) catch return error.InvalidDnsResponse; defer parsed.deinit();
const dns_response = parsed.value; if (dns_response.Answer == null or dns_response.Answer.?.len == 0) { return error.NoDnsRecordsFound; }
for (dns_response.Answer.?) |answer| { const data = answer.data orelse continue; const did_str = extractDidFromTxt(data) orelse continue;
if (Did.parse(did_str) != null) { return try self.allocator.dupe(u8, did_str); } }
return error.NoValidDidFound; }};
fn extractDidFromTxt(txt_data: []const u8) ?[]const u8 { var data = txt_data; if (data.len >= 2 and data[0] == '"' and data[data.len - 1] == '"') { data = data[1 .. data.len - 1]; }
const prefix = "did="; if (std.mem.startsWith(u8, data, prefix)) { return data[prefix.len..]; }
return null;}
const DnsResponse = struct { Status: i32, TC: bool, RD: bool, RA: bool, AD: bool, CD: bool, Question: ?[]Question = null, Answer: ?[]Answer = null,};
const Question = struct { name: []const u8, type: i32,};
const Answer = struct { name: []const u8, type: i32, TTL: i32, data: ?[]const u8 = null,};
// === integration tests ===// these actually hit the network - run with: zig test src/internal/handle_resolver.zig
test "resolve handle (http) - integration" { // use arena for http client internals that may leak var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit();
var resolver = HandleResolver.init(std.Options.debug_io, arena.allocator()); defer resolver.deinit();
// resolve a known handle that has .well-known/atproto-did const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle; const did = resolver.resolveHttp(handle) catch { return; // network error, expected in CI };
// should be a valid did:plc try std.testing.expect(Did.parse(did) != null); try std.testing.expect(std.mem.startsWith(u8, did, "did:plc:"));}
test "resolve handle (dns over http) - integration" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit();
var resolver = HandleResolver.init(std.Options.debug_io, arena.allocator()); defer resolver.deinit();
const handle = Handle.parse("seiso.moe") orelse return error.InvalidHandle; const did = resolver.resolveDns(handle) catch { return; // network error, expected in CI };
// should be a valid DID try std.testing.expect(Did.parse(did) != null); try std.testing.expect(std.mem.startsWith(u8, did, "did:"));}
test "resolve handle - integration" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit();
var resolver = HandleResolver.init(std.Options.debug_io, arena.allocator()); defer resolver.deinit();
const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle; const did = resolver.resolve(handle) catch { return; // network error, expected in CI };
// should be a valid DID try std.testing.expect(Did.parse(did) != null); try std.testing.expect(std.mem.startsWith(u8, did, "did:"));}