atproto utils for zig
zat.dev
atproto
sdk
zig
1//! interop tests against bluesky-social/atproto-interop-tests fixtures
2//!
3//! validates zat's parsers and crypto against the official test vectors.
4
5const std = @import("std");
6
7// types under test
8const Tid = @import("../syntax/tid.zig").Tid;
9const Did = @import("../syntax/did.zig").Did;
10const Handle = @import("../syntax/handle.zig").Handle;
11const Nsid = @import("../syntax/nsid.zig").Nsid;
12const Rkey = @import("../syntax/rkey.zig").Rkey;
13const AtUri = @import("../syntax/at_uri.zig").AtUri;
14
15// crypto
16const jwt = @import("../crypto/jwt.zig");
17const Keypair = @import("../crypto/keypair.zig").Keypair;
18const multibase = @import("../crypto/multibase.zig");
19const multicodec = @import("../crypto/multicodec.zig");
20
21// repo
22const mst = @import("../repo/mst.zig");
23const cbor = @import("../repo/cbor.zig");
24
25// === helpers ===
26
27fn LineIterator(comptime sentinel: ?u8) type {
28 return struct {
29 inner: std.mem.SplitIterator(u8, .scalar),
30
31 const Self = @This();
32
33 fn init(data: []const u8) Self {
34 // strip trailing sentinel if present (some files end with \n)
35 const trimmed = if (sentinel) |s|
36 if (data.len > 0 and data[data.len - 1] == s) data[0 .. data.len - 1] else data
37 else
38 data;
39 return .{ .inner = std.mem.splitScalar(u8, trimmed, '\n') };
40 }
41
42 fn next(self: *Self) ?[]const u8 {
43 while (self.inner.next()) |line| {
44 // skip blank lines and comments
45 if (line.len == 0) continue;
46 if (line[0] == '#') continue;
47 // strip trailing \r for windows line endings
48 const trimmed = if (line.len > 0 and line[line.len - 1] == '\r')
49 line[0 .. line.len - 1]
50 else
51 line;
52 if (trimmed.len == 0) continue;
53 return trimmed;
54 }
55 return null;
56 }
57 };
58}
59
60fn testLinesSentinel(comptime data: [:0]const u8) LineIterator(0) {
61 return LineIterator(0).init(data);
62}
63
64/// run syntax validation tests for a parser type
65fn syntaxTest(
66 comptime valid_data: [:0]const u8,
67 comptime invalid_data: [:0]const u8,
68 comptime parseFn: anytype,
69) !void {
70 // test valid lines
71 var valid_lines = testLinesSentinel(valid_data);
72 var valid_count: usize = 0;
73 while (valid_lines.next()) |line| {
74 if (parseFn(line) == null) {
75 std.debug.print("FAIL: expected valid, got null for: '{s}'\n", .{line});
76 return error.ExpectedValid;
77 }
78 valid_count += 1;
79 }
80 if (valid_count == 0) return error.NoTestCases;
81
82 // test invalid lines
83 var invalid_lines = testLinesSentinel(invalid_data);
84 var invalid_count: usize = 0;
85 while (invalid_lines.next()) |line| {
86 if (parseFn(line) != null) {
87 std.debug.print("FAIL: expected null, got valid for: '{s}'\n", .{line});
88 return error.ExpectedInvalid;
89 }
90 invalid_count += 1;
91 }
92 if (invalid_count == 0) return error.NoTestCases;
93}
94
95// === tier 1: syntax validation ===
96
97test "interop: tid syntax" {
98 try syntaxTest(
99 @embedFile("tid_syntax_valid"),
100 @embedFile("tid_syntax_invalid"),
101 Tid.parse,
102 );
103}
104
105test "interop: did syntax" {
106 try syntaxTest(
107 @embedFile("did_syntax_valid"),
108 @embedFile("did_syntax_invalid"),
109 Did.parse,
110 );
111}
112
113test "interop: handle syntax" {
114 try syntaxTest(
115 @embedFile("handle_syntax_valid"),
116 @embedFile("handle_syntax_invalid"),
117 Handle.parse,
118 );
119}
120
121test "interop: nsid syntax" {
122 try syntaxTest(
123 @embedFile("nsid_syntax_valid"),
124 @embedFile("nsid_syntax_invalid"),
125 Nsid.parse,
126 );
127}
128
129test "interop: rkey syntax" {
130 try syntaxTest(
131 @embedFile("recordkey_syntax_valid"),
132 @embedFile("recordkey_syntax_invalid"),
133 Rkey.parse,
134 );
135}
136
137test "interop: aturi syntax" {
138 try syntaxTest(
139 @embedFile("aturi_syntax_valid"),
140 @embedFile("aturi_syntax_invalid"),
141 AtUri.parse,
142 );
143}
144
145// === tier 2: crypto signature verification ===
146
147fn base64StdDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
148 // try standard (padded) first, fall back to no-pad
149 const decoder = if (input.len > 0 and input[input.len - 1] == '=')
150 &std.base64.standard.Decoder
151 else
152 &std.base64.standard_no_pad.Decoder;
153
154 const size = decoder.calcSizeForSlice(input) catch return error.InvalidBase64;
155 const output = try allocator.alloc(u8, size);
156 errdefer allocator.free(output);
157 decoder.decode(output, input) catch return error.InvalidBase64;
158 return output;
159}
160
161test "interop: crypto signature verification" {
162 const allocator = std.testing.allocator;
163
164 const fixture_json = @embedFile("signature_fixtures");
165 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{});
166 defer parsed.deinit();
167
168 const fixtures = parsed.value.array.items;
169 var tested: usize = 0;
170
171 for (fixtures) |fixture| {
172 const obj = fixture.object;
173
174 const comment = if (obj.get("comment")) |v| switch (v) {
175 .string => |s| s,
176 else => "?",
177 } else "?";
178
179 const message_b64 = obj.get("messageBase64").?.string;
180 const algorithm = obj.get("algorithm").?.string;
181 const pub_key_did = obj.get("publicKeyDid").?.string;
182 const sig_b64 = obj.get("signatureBase64").?.string;
183 const valid = obj.get("validSignature").?.bool;
184
185 // extract multibase key from did:key (strip "did:key:" prefix)
186 const did_key_prefix = "did:key:";
187 if (!std.mem.startsWith(u8, pub_key_did, did_key_prefix)) return error.InvalidDidKey;
188 const multibase_key = pub_key_did[did_key_prefix.len..];
189
190 // decode message and signature
191 const message = try base64StdDecode(allocator, message_b64);
192 defer allocator.free(message);
193
194 const sig_bytes = base64StdDecode(allocator, sig_b64) catch |err| {
195 // DER-encoded sigs may fail to decode at expected length — that's fine for invalid
196 if (!valid) {
197 tested += 1;
198 continue;
199 }
200 return err;
201 };
202 defer allocator.free(sig_bytes);
203
204 // decode public key from multibase+multicodec (did:key format)
205 const key_bytes = try multibase.decode(allocator, multibase_key);
206 defer allocator.free(key_bytes);
207
208 const parsed_key = try multicodec.parsePublicKey(key_bytes);
209
210 // verify signature
211 const verify_result = if (std.mem.eql(u8, algorithm, "ES256K"))
212 jwt.verifySecp256k1(message, sig_bytes, parsed_key.raw)
213 else if (std.mem.eql(u8, algorithm, "ES256"))
214 jwt.verifyP256(message, sig_bytes, parsed_key.raw)
215 else
216 error.UnsupportedAlgorithm;
217
218 if (valid) {
219 verify_result catch |err| {
220 std.debug.print("FAIL: expected valid signature but got {s}: {s}\n", .{ @errorName(err), comment });
221 return error.ExpectedValidSignature;
222 };
223 } else {
224 if (verify_result) |_| {
225 std.debug.print("FAIL: expected invalid signature but verified OK: {s}\n", .{comment});
226 return error.ExpectedInvalidSignature;
227 } else |_| {}
228 }
229
230 tested += 1;
231 }
232
233 // should have tested all 6 fixtures
234 try std.testing.expect(tested == fixtures.len);
235}
236
237// === tier 2b: did:key derivation ===
238
239test "interop: did:key derivation K256" {
240 const allocator = std.testing.allocator;
241
242 const fixture_json = @embedFile("w3c_didkey_K256");
243 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{});
244 defer parsed.deinit();
245
246 const fixtures = parsed.value.array.items;
247 var tested: usize = 0;
248
249 for (fixtures) |fixture| {
250 const obj = fixture.object;
251 const hex_str = obj.get("privateKeyBytesHex").?.string;
252 const expected_did = obj.get("publicDidKey").?.string;
253
254 var sk_bytes: [32]u8 = undefined;
255 _ = std.fmt.hexToBytes(&sk_bytes, hex_str) catch return error.InvalidHex;
256
257 const kp = try Keypair.fromSecretKey(.secp256k1, sk_bytes);
258 const actual_did = try kp.did(allocator);
259 defer allocator.free(actual_did);
260
261 if (!std.mem.eql(u8, actual_did, expected_did)) {
262 std.debug.print("FAIL K256: expected {s}, got {s}\n", .{ expected_did, actual_did });
263 return error.DidKeyMismatch;
264 }
265 tested += 1;
266 }
267
268 try std.testing.expectEqual(@as(usize, 5), tested);
269}
270
271test "interop: did:key derivation P256" {
272 const allocator = std.testing.allocator;
273
274 const fixture_json = @embedFile("w3c_didkey_P256");
275 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{});
276 defer parsed.deinit();
277
278 const fixtures = parsed.value.array.items;
279 var tested: usize = 0;
280
281 for (fixtures) |fixture| {
282 const obj = fixture.object;
283 const b58_str = obj.get("privateKeyBytesBase58").?.string;
284 const expected_did = obj.get("publicDidKey").?.string;
285
286 // raw base58 (no multibase 'z' prefix)
287 const decoded = try multibase.base58btc.decode(allocator, b58_str);
288 defer allocator.free(decoded);
289 if (decoded.len < 32) return error.KeyTooShort;
290
291 const kp = try Keypair.fromSecretKey(.p256, decoded[0..32].*);
292 const actual_did = try kp.did(allocator);
293 defer allocator.free(actual_did);
294
295 if (!std.mem.eql(u8, actual_did, expected_did)) {
296 std.debug.print("FAIL P256: expected {s}, got {s}\n", .{ expected_did, actual_did });
297 return error.DidKeyMismatch;
298 }
299 tested += 1;
300 }
301
302 try std.testing.expectEqual(@as(usize, 1), tested);
303}
304
305// === tier 2c: data model round-trip ===
306
307/// convert AT Protocol JSON to CBOR value
308/// handles $link (CID) and $bytes (byte string) special types
309fn jsonToCbor(allocator: std.mem.Allocator, json: std.json.Value) !cbor.Value {
310 switch (json) {
311 .object => |obj| {
312 // check for $link → CID
313 if (obj.get("$link")) |link_val| {
314 const link_str = switch (link_val) {
315 .string => |s| s,
316 else => return error.InvalidLink,
317 };
318 // bafyrei... is base32lower multibase (without 'b' prefix in the $link value,
319 // but CID strings in AT Protocol use the full multibase-prefixed form)
320 // actually the fixture CIDs start with "bafyrei" which is base32lower with 'b' prefix
321 const raw = try multibase.base32lower.decode(allocator, link_str[1..]);
322 return .{ .cid = .{ .raw = raw } };
323 }
324 // check for $bytes → byte string
325 if (obj.get("$bytes")) |bytes_val| {
326 const b64_str = switch (bytes_val) {
327 .string => |s| s,
328 else => return error.InvalidBytes,
329 };
330 const decoded = try base64StdDecode(allocator, b64_str);
331 return .{ .bytes = decoded };
332 }
333 // regular object → map
334 const entries = try allocator.alloc(cbor.Value.MapEntry, obj.count());
335 var i: usize = 0;
336 var it = obj.iterator();
337 while (it.next()) |kv| {
338 entries[i] = .{
339 .key = kv.key_ptr.*,
340 .value = try jsonToCbor(allocator, kv.value_ptr.*),
341 };
342 i += 1;
343 }
344 return .{ .map = entries };
345 },
346 .array => |arr| {
347 const items = try allocator.alloc(cbor.Value, arr.items.len);
348 for (arr.items, 0..) |item, i| {
349 items[i] = try jsonToCbor(allocator, item);
350 }
351 return .{ .array = items };
352 },
353 .string => |s| return .{ .text = s },
354 .integer => |n| {
355 if (n >= 0) return .{ .unsigned = @intCast(n) };
356 return .{ .negative = n };
357 },
358 .float => |f| {
359 // DAG-CBOR has no floats; coerce integer-valued floats
360 const int_val: i64 = @intFromFloat(f);
361 if (@as(f64, @floatFromInt(int_val)) != f) return error.UnsupportedFloat;
362 if (int_val >= 0) return .{ .unsigned = @intCast(int_val) };
363 return .{ .negative = int_val };
364 },
365 .null => return .null,
366 .bool => |b| return .{ .boolean = b },
367 .number_string => return error.UnsupportedNumberString,
368 }
369}
370
371test "interop: data model fixtures" {
372 const allocator = std.testing.allocator;
373
374 const fixture_json = @embedFile("data_model_fixtures");
375 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{});
376 defer parsed.deinit();
377
378 const fixtures = parsed.value.array.items;
379 var tested: usize = 0;
380
381 for (fixtures) |fixture| {
382 var arena = std.heap.ArenaAllocator.init(allocator);
383 defer arena.deinit();
384 const a = arena.allocator();
385
386 const obj = fixture.object;
387 const json_val = obj.get("json").?;
388 const expected_cbor_b64 = obj.get("cbor_base64").?.string;
389 const expected_cid_str = obj.get("cid").?.string;
390
391 // convert JSON → CBOR value → encoded bytes
392 const cbor_val = try jsonToCbor(a, json_val);
393 const encoded = try cbor.encodeAlloc(a, cbor_val);
394
395 // compare encoded bytes with expected
396 const expected_bytes = try base64StdDecode(a, expected_cbor_b64);
397 if (!std.mem.eql(u8, encoded, expected_bytes)) {
398 std.debug.print("FAIL data model: CBOR encoding mismatch for fixture {d}\n", .{tested});
399 std.debug.print(" expected ({d} bytes): ", .{expected_bytes.len});
400 for (expected_bytes) |b| std.debug.print("{x:0>2}", .{b});
401 std.debug.print("\n actual ({d} bytes): ", .{encoded.len});
402 for (encoded) |b| std.debug.print("{x:0>2}", .{b});
403 std.debug.print("\n", .{});
404 return error.CborEncodingMismatch;
405 }
406
407 // compute CID and compare
408 const cid = try cbor.Cid.forDagCbor(a, encoded);
409 // format as base32lower multibase string: "b" + base32lower(raw)
410 const cid_str = try multibase.base32lower.encode(a, cid.raw);
411 if (!std.mem.eql(u8, cid_str, expected_cid_str)) {
412 std.debug.print("FAIL data model: CID mismatch for fixture {d}\n", .{tested});
413 std.debug.print(" expected: {s}\n actual: {s}\n", .{ expected_cid_str, cid_str });
414 return error.CidMismatch;
415 }
416
417 tested += 1;
418 }
419
420 try std.testing.expectEqual(@as(usize, 3), tested);
421}
422
423// === tier 3: MST ===
424
425test "interop: mst key heights" {
426 const allocator = std.testing.allocator;
427
428 const fixture_json = @embedFile("mst_key_heights");
429 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{});
430 defer parsed.deinit();
431
432 const fixtures = parsed.value.array.items;
433 var tested: usize = 0;
434
435 for (fixtures) |fixture| {
436 const obj = fixture.object;
437 const key = obj.get("key").?.string;
438 const expected_height: u32 = @intCast(obj.get("height").?.integer);
439
440 const actual = mst.keyHeight(key);
441 if (actual != expected_height) {
442 std.debug.print("FAIL: key '{s}': expected height {d}, got {d}\n", .{ key, expected_height, actual });
443 return error.WrongHeight;
444 }
445 tested += 1;
446 }
447
448 try std.testing.expect(tested > 0);
449}
450
451test "interop: mst common prefix" {
452 const allocator = std.testing.allocator;
453
454 const fixture_json = @embedFile("common_prefix");
455 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{});
456 defer parsed.deinit();
457
458 const fixtures = parsed.value.array.items;
459 var tested: usize = 0;
460
461 for (fixtures) |fixture| {
462 const obj = fixture.object;
463 const left = obj.get("left").?.string;
464 const right = obj.get("right").?.string;
465 const expected_len: usize = @intCast(obj.get("len").?.integer);
466
467 const actual = mst.commonPrefixLen(left, right);
468 if (actual != expected_len) {
469 std.debug.print("FAIL: commonPrefixLen('{s}', '{s}'): expected {d}, got {d}\n", .{ left, right, expected_len, actual });
470 return error.WrongPrefixLen;
471 }
472 tested += 1;
473 }
474
475 try std.testing.expect(tested == 13);
476}
477
478test "interop: mst commit proofs" {
479 const allocator = std.testing.allocator;
480
481 const fixture_json = @embedFile("commit_proofs");
482 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{});
483 defer parsed.deinit();
484
485 const fixtures = parsed.value.array.items;
486 var tested: usize = 0;
487
488 for (fixtures) |fixture| {
489 var arena = std.heap.ArenaAllocator.init(allocator);
490 defer arena.deinit();
491 const a = arena.allocator();
492
493 const obj = fixture.object;
494 const comment = if (obj.get("comment")) |v| switch (v) {
495 .string => |s| s,
496 else => "?",
497 } else "?";
498
499 // parse leaf value CID
500 const leaf_value_str = obj.get("leafValue").?.string;
501 const leaf_cid = try mst.parseCidString(a, leaf_value_str);
502
503 // build initial tree from keys
504 var tree = mst.Mst.init(a);
505 const keys = obj.get("keys").?.array.items;
506 for (keys) |key_val| {
507 try tree.put(key_val.string, leaf_cid);
508 }
509
510 // verify root before commit
511 const root_before_str = obj.get("rootBeforeCommit").?.string;
512 const expected_before = try mst.parseCidString(a, root_before_str);
513
514 const actual_before = try tree.rootCid();
515 if (!std.mem.eql(u8, actual_before.raw, expected_before.raw)) {
516 std.debug.print("FAIL [{s}]: rootBeforeCommit mismatch\n", .{comment});
517 std.debug.print(" expected: {s}\n", .{root_before_str});
518 // print hex for debugging
519 std.debug.print(" expected raw ({d}): ", .{expected_before.raw.len});
520 for (expected_before.raw) |b| std.debug.print("{x:0>2}", .{b});
521 std.debug.print("\n actual raw ({d}): ", .{actual_before.raw.len});
522 for (actual_before.raw) |b| std.debug.print("{x:0>2}", .{b});
523 std.debug.print("\n", .{});
524 return error.RootBeforeMismatch;
525 }
526
527 // apply adds
528 const adds = obj.get("adds").?.array.items;
529 for (adds) |add_val| {
530 try tree.put(add_val.string, leaf_cid);
531 }
532
533 // apply dels
534 const dels = obj.get("dels").?.array.items;
535 for (dels) |del_val| {
536 try tree.delete(del_val.string);
537 }
538
539 // verify root after commit
540 const root_after_str = obj.get("rootAfterCommit").?.string;
541 const expected_after = try mst.parseCidString(a, root_after_str);
542
543 const actual_after = try tree.rootCid();
544 if (!std.mem.eql(u8, actual_after.raw, expected_after.raw)) {
545 std.debug.print("FAIL [{s}]: rootAfterCommit mismatch\n", .{comment});
546 std.debug.print(" expected: {s}\n", .{root_after_str});
547 std.debug.print(" expected raw ({d}): ", .{expected_after.raw.len});
548 for (expected_after.raw) |b| std.debug.print("{x:0>2}", .{b});
549 std.debug.print("\n actual raw ({d}): ", .{actual_after.raw.len});
550 for (actual_after.raw) |b| std.debug.print("{x:0>2}", .{b});
551 std.debug.print("\n", .{});
552 return error.RootAfterMismatch;
553 }
554
555 tested += 1;
556 }
557
558 try std.testing.expect(tested == 6);
559}