atproto utils for zig zat.dev
atproto sdk zig
at main 6.3 kB view raw
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}