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