1//! DID - Decentralized Identifier
2//!
3//! dids are persistent, long-term account identifiers based on W3C standard.
4//! format: did:<method>:<identifier>
5//!
6//! validation rules:
7//! - max 2048 characters
8//! - method must be lowercase letters only
9//! - identifier allows: a-zA-Z0-9._:%-
10//! - cannot end with : or %
11//! - cannot contain: / ? # [ ] @
12//!
13//! see: https://atproto.com/specs/did
14
15const std = @import("std");
16
17pub const Did = struct {
18 /// the full did string (borrowed, not owned)
19 raw: []const u8,
20
21 /// offset where method starts (after "did:")
22 method_start: usize,
23
24 /// offset where method ends / identifier starts
25 id_start: usize,
26
27 pub const max_length = 2048;
28
29 pub const Method = enum {
30 plc,
31 web,
32 other,
33 };
34
35 /// parse a did string. returns null if invalid.
36 pub fn parse(s: []const u8) ?Did {
37 // length check
38 if (s.len == 0 or s.len > max_length) return null;
39
40 // must start with "did:"
41 if (!std.mem.startsWith(u8, s, "did:")) return null;
42
43 // find method end (next colon)
44 const after_did = s[4..];
45 const method_end = std.mem.indexOfScalar(u8, after_did, ':') orelse return null;
46 if (method_end == 0) return null; // empty method
47
48 // method must be lowercase letters only
49 const method_str = after_did[0..method_end];
50 for (method_str) |c| {
51 if (c < 'a' or c > 'z') return null;
52 }
53
54 // identifier must not be empty
55 const id_offset = 4 + method_end + 1;
56 if (id_offset >= s.len) return null;
57
58 const id_part = s[id_offset..];
59
60 // cannot end with : or %
61 const last = id_part[id_part.len - 1];
62 if (last == ':' or last == '%') return null;
63
64 // validate identifier characters
65 if (!isValidIdentifier(id_part)) return null;
66
67 return .{
68 .raw = s,
69 .method_start = 4,
70 .id_start = id_offset,
71 };
72 }
73
74 /// the method portion (e.g., "plc", "web")
75 pub fn methodStr(self: Did) []const u8 {
76 return self.raw[self.method_start .. self.id_start - 1];
77 }
78
79 /// the method as an enum (plc, web, or other)
80 pub fn method(self: Did) Method {
81 const m = self.methodStr();
82 if (std.mem.eql(u8, m, "plc")) return .plc;
83 if (std.mem.eql(u8, m, "web")) return .web;
84 return .other;
85 }
86
87 /// the method-specific identifier
88 pub fn identifier(self: Did) []const u8 {
89 return self.raw[self.id_start..];
90 }
91
92 /// check if this is a plc did
93 pub fn isPlc(self: Did) bool {
94 return self.method() == .plc;
95 }
96
97 /// check if this is a web did
98 pub fn isWeb(self: Did) bool {
99 return self.method() == .web;
100 }
101
102 /// get the full did string
103 pub fn str(self: Did) []const u8 {
104 return self.raw;
105 }
106
107 fn isValidIdentifier(id: []const u8) bool {
108 for (id) |c| {
109 const valid = switch (c) {
110 'a'...'z', 'A'...'Z', '0'...'9' => true,
111 '.', '_', ':', '-', '%' => true,
112 // explicitly reject invalid chars
113 '/', '?', '#', '[', ']', '@' => false,
114 else => false,
115 };
116 if (!valid) return false;
117 }
118 return true;
119 }
120};
121
122// === tests from atproto.com/specs/did ===
123
124test "valid: did:plc example" {
125 const did = Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur") orelse return error.InvalidDid;
126 try std.testing.expect(did.isPlc());
127 try std.testing.expectEqualStrings("plc", did.methodStr());
128 try std.testing.expectEqualStrings("z72i7hdynmk6r22z27h6tvur", did.identifier());
129}
130
131test "valid: did:web example" {
132 const did = Did.parse("did:web:blueskyweb.xyz") orelse return error.InvalidDid;
133 try std.testing.expect(did.isWeb());
134 try std.testing.expectEqualStrings("web", did.methodStr());
135 try std.testing.expectEqualStrings("blueskyweb.xyz", did.identifier());
136}
137
138test "valid: did:web with port" {
139 const did = Did.parse("did:web:localhost%3A8080") orelse return error.InvalidDid;
140 try std.testing.expect(did.isWeb());
141 try std.testing.expectEqualStrings("localhost%3A8080", did.identifier());
142}
143
144test "valid: other method" {
145 const did = Did.parse("did:example:123456") orelse return error.InvalidDid;
146 try std.testing.expect(did.method() == .other);
147 try std.testing.expectEqualStrings("example", did.methodStr());
148}
149
150test "valid: identifier with allowed special chars" {
151 try std.testing.expect(Did.parse("did:plc:abc.def") != null);
152 try std.testing.expect(Did.parse("did:plc:abc_def") != null);
153 try std.testing.expect(Did.parse("did:plc:abc:def") != null);
154 try std.testing.expect(Did.parse("did:plc:abc-def") != null);
155 try std.testing.expect(Did.parse("did:plc:abc%20def") != null);
156}
157
158test "invalid: missing prefix" {
159 try std.testing.expect(Did.parse("plc:xyz") == null);
160 try std.testing.expect(Did.parse("xyz") == null);
161}
162
163test "invalid: uppercase method" {
164 try std.testing.expect(Did.parse("did:PLC:xyz") == null);
165 try std.testing.expect(Did.parse("did:METHOD:val") == null);
166}
167
168test "invalid: empty method" {
169 try std.testing.expect(Did.parse("did::xyz") == null);
170}
171
172test "invalid: empty identifier" {
173 try std.testing.expect(Did.parse("did:plc:") == null);
174 try std.testing.expect(Did.parse("did:web:") == null);
175}
176
177test "invalid: ends with colon or percent" {
178 try std.testing.expect(Did.parse("did:plc:abc:") == null);
179 try std.testing.expect(Did.parse("did:plc:abc%") == null);
180}
181
182test "invalid: contains forbidden chars" {
183 try std.testing.expect(Did.parse("did:plc:abc/def") == null);
184 try std.testing.expect(Did.parse("did:plc:abc?def") == null);
185 try std.testing.expect(Did.parse("did:plc:abc#def") == null);
186 try std.testing.expect(Did.parse("did:plc:abc[def") == null);
187 try std.testing.expect(Did.parse("did:plc:abc]def") == null);
188 try std.testing.expect(Did.parse("did:plc:abc@def") == null);
189}
190
191test "invalid: too long" {
192 // create a did longer than 2048 chars
193 var buf: [2100]u8 = undefined;
194 @memset(&buf, 'a');
195 @memcpy(buf[0..8], "did:plc:");
196 try std.testing.expect(Did.parse(&buf) == null);
197}