atproto utils for zig
zat.dev
atproto
sdk
zig
1//! DID Resolver - fetches and parses DID documents
2//!
3//! resolves did:plc via plc.directory and did:web via .well-known/did.json
4//!
5//! see: https://atproto.com/specs/did
6
7const std = @import("std");
8const Did = @import("../syntax/did.zig").Did;
9const DidDocument = @import("did_document.zig").DidDocument;
10const HttpTransport = @import("../xrpc/transport.zig").HttpTransport;
11
12pub const DidResolver = struct {
13 allocator: std.mem.Allocator,
14 transport: HttpTransport,
15
16 /// plc directory url (default: https://plc.directory)
17 plc_url: []const u8 = "https://plc.directory",
18
19 pub fn init(allocator: std.mem.Allocator) DidResolver {
20 return initWithOptions(allocator, .{});
21 }
22
23 pub const Options = struct {
24 keep_alive: bool = true,
25 };
26
27 pub fn initWithOptions(allocator: std.mem.Allocator, options: Options) DidResolver {
28 var transport = HttpTransport.init(allocator);
29 transport.keep_alive = options.keep_alive;
30 return .{
31 .allocator = allocator,
32 .transport = transport,
33 };
34 }
35
36 pub fn deinit(self: *DidResolver) void {
37 self.transport.deinit();
38 }
39
40 /// resolve a did to its document
41 pub fn resolve(self: *DidResolver, did: Did) !DidDocument {
42 return switch (did.method()) {
43 .plc => try self.resolvePlc(did),
44 .web => try self.resolveWeb(did),
45 .other => error.UnsupportedDidMethod,
46 };
47 }
48
49 /// resolve did:plc via plc.directory
50 fn resolvePlc(self: *DidResolver, did: Did) !DidDocument {
51 // build url: {plc_url}/{did}
52 const url = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.plc_url, did.raw });
53 defer self.allocator.free(url);
54
55 return try self.fetchDidDocument(url);
56 }
57
58 /// resolve did:web via .well-known
59 fn resolveWeb(self: *DidResolver, did: Did) !DidDocument {
60 // did:web:example.com -> https://example.com/.well-known/did.json
61 // did:web:example.com:path:to -> https://example.com/path/to/did.json
62 const domain_and_path = did.raw["did:web:".len..];
63
64 // decode percent-encoded colons in path
65 var url_buf: std.ArrayList(u8) = .empty;
66 defer url_buf.deinit(self.allocator);
67
68 try url_buf.appendSlice(self.allocator, "https://");
69
70 var first_segment = true;
71 var it = std.mem.splitScalar(u8, domain_and_path, ':');
72 while (it.next()) |segment| {
73 if (first_segment) {
74 // first segment is the domain
75 try url_buf.appendSlice(self.allocator, segment);
76 first_segment = false;
77 } else {
78 // subsequent segments are path components
79 try url_buf.append(self.allocator, '/');
80 try url_buf.appendSlice(self.allocator, segment);
81 }
82 }
83
84 // add .well-known/did.json or /did.json
85 if (std.mem.indexOf(u8, domain_and_path, ":") == null) {
86 // no path, use .well-known
87 try url_buf.appendSlice(self.allocator, "/.well-known/did.json");
88 } else {
89 // has path, append did.json
90 try url_buf.appendSlice(self.allocator, "/did.json");
91 }
92
93 return try self.fetchDidDocument(url_buf.items);
94 }
95
96 /// fetch and parse a did document from url
97 fn fetchDidDocument(self: *DidResolver, url: []const u8) !DidDocument {
98 const result = self.transport.fetch(.{ .url = url }) catch return error.DidResolutionFailed;
99 defer self.allocator.free(result.body);
100
101 if (result.status != .ok) {
102 return error.DidResolutionFailed;
103 }
104
105 return try DidDocument.parse(self.allocator, result.body);
106 }
107};
108
109// === tests ===
110
111test "resolve did:plc - integration" {
112 // use arena for http client internals that may leak
113 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
114 defer arena.deinit();
115
116 var resolver = DidResolver.init(arena.allocator());
117 defer resolver.deinit();
118
119 const did = Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur").?;
120 var doc = resolver.resolve(did) catch |err| {
121 // network errors are ok in CI, but compilation must succeed
122 std.debug.print("network error (expected in CI): {}\n", .{err});
123 return;
124 };
125 defer doc.deinit();
126
127 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id);
128 try std.testing.expect(doc.handle() != null);
129}
130
131test "resolve did:plc - leak check (no arena)" {
132 // repro for memory leak report: use testing.allocator directly
133 // (no arena) to see if std.http.Client leaks on deinit
134 var resolver = DidResolver.init(std.testing.allocator);
135 defer resolver.deinit();
136
137 const did = Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur").?;
138 var doc = resolver.resolve(did) catch |err| {
139 std.debug.print("network error (expected in CI): {}\n", .{err});
140 return;
141 };
142 defer doc.deinit();
143
144 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id);
145}
146
147test "did:web url construction" {
148 // test url building without network
149 var resolver = DidResolver.init(std.testing.allocator);
150 defer resolver.deinit();
151
152 // simple domain
153 {
154 const did = Did.parse("did:web:example.com").?;
155 _ = did;
156 // would resolve to https://example.com/.well-known/did.json
157 }
158
159 // domain with path
160 {
161 const did = Did.parse("did:web:example.com:user:alice").?;
162 _ = did;
163 // would resolve to https://example.com/user/alice/did.json
164 }
165}