atproto utils for zig
Something went wrong. Try again.
Zig
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560//! interop tests against bluesky-social/atproto-interop-tests fixtures//!//! validates zat's parsers and crypto against the official test vectors.
const std = @import("std");
// types under testconst Tid = @import("../syntax/tid.zig").Tid;const Did = @import("../syntax/did.zig").Did;const Handle = @import("../syntax/handle.zig").Handle;const Nsid = @import("../syntax/nsid.zig").Nsid;const Rkey = @import("../syntax/rkey.zig").Rkey;const AtUri = @import("../syntax/at_uri.zig").AtUri;
// cryptoconst jwt = @import("../crypto/jwt.zig");const Keypair = @import("../crypto/keypair.zig").Keypair;const multibase = @import("../crypto/multibase.zig");const multicodec = @import("../crypto/multicodec.zig");
// repoconst mst = @import("../repo/mst.zig");const cbor = @import("../repo/cbor.zig");
// === helpers ===
fn LineIterator(comptime sentinel: ?u8) type { return struct { inner: std.mem.SplitIterator(u8, .scalar),
const Self = @This();
fn init(data: []const u8) Self { // strip trailing sentinel if present (some files end with \n) const trimmed = if (sentinel) |s| if (data.len > 0 and data[data.len - 1] == s) data[0 .. data.len - 1] else data else data; return .{ .inner = std.mem.splitScalar(u8, trimmed, '\n') }; }
fn next(self: *Self) ?[]const u8 { while (self.inner.next()) |line| { // skip blank lines and comments if (line.len == 0) continue; if (line[0] == '#') continue; // strip trailing \r for windows line endings const trimmed = if (line.len > 0 and line[line.len - 1] == '\r') line[0 .. line.len - 1] else line; if (trimmed.len == 0) continue; return trimmed; } return null; } };}
fn testLinesSentinel(comptime data: [:0]const u8) LineIterator(0) { return LineIterator(0).init(data);}
/// run syntax validation tests for a parser typefn syntaxTest( comptime valid_data: [:0]const u8, comptime invalid_data: [:0]const u8, comptime parseFn: anytype,) !void { // test valid lines var valid_lines = testLinesSentinel(valid_data); var valid_count: usize = 0; while (valid_lines.next()) |line| { if (parseFn(line) == null) { std.debug.print("FAIL: expected valid, got null for: '{s}'\n", .{line}); return error.ExpectedValid; } valid_count += 1; } if (valid_count == 0) return error.NoTestCases;
// test invalid lines var invalid_lines = testLinesSentinel(invalid_data); var invalid_count: usize = 0; while (invalid_lines.next()) |line| { if (parseFn(line) != null) { std.debug.print("FAIL: expected null, got valid for: '{s}'\n", .{line}); return error.ExpectedInvalid; } invalid_count += 1; } if (invalid_count == 0) return error.NoTestCases;}
// === tier 1: syntax validation ===
test "interop: tid syntax" { try syntaxTest( @embedFile("tid_syntax_valid"), @embedFile("tid_syntax_invalid"), Tid.parse, );}
test "interop: did syntax" { try syntaxTest( @embedFile("did_syntax_valid"), @embedFile("did_syntax_invalid"), Did.parse, );}
test "interop: handle syntax" { try syntaxTest( @embedFile("handle_syntax_valid"), @embedFile("handle_syntax_invalid"), Handle.parse, );}
test "interop: nsid syntax" { try syntaxTest( @embedFile("nsid_syntax_valid"), @embedFile("nsid_syntax_invalid"), Nsid.parse, );}
test "interop: rkey syntax" { try syntaxTest( @embedFile("recordkey_syntax_valid"), @embedFile("recordkey_syntax_invalid"), Rkey.parse, );}
test "interop: aturi syntax" { try syntaxTest( @embedFile("aturi_syntax_valid"), @embedFile("aturi_syntax_invalid"), AtUri.parse, );}
// === tier 2: crypto signature verification ===
fn base64StdDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { // try standard (padded) first, fall back to no-pad const decoder = if (input.len > 0 and input[input.len - 1] == '=') &std.base64.standard.Decoder else &std.base64.standard_no_pad.Decoder;
const size = decoder.calcSizeForSlice(input) catch return error.InvalidBase64; const output = try allocator.alloc(u8, size); errdefer allocator.free(output); decoder.decode(output, input) catch return error.InvalidBase64; return output;}
test "interop: crypto signature verification" { const allocator = std.testing.allocator;
const fixture_json = @embedFile("signature_fixtures"); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); defer parsed.deinit();
const fixtures = parsed.value.array.items; var tested: usize = 0;
for (fixtures) |fixture| { const obj = fixture.object;
const comment = if (obj.get("comment")) |v| switch (v) { .string => |s| s, else => "?", } else "?";
const message_b64 = obj.get("messageBase64").?.string; const algorithm = obj.get("algorithm").?.string; const pub_key_did = obj.get("publicKeyDid").?.string; const sig_b64 = obj.get("signatureBase64").?.string; const valid = obj.get("validSignature").?.bool;
// extract multibase key from did:key (strip "did:key:" prefix) const did_key_prefix = "did:key:"; if (!std.mem.startsWith(u8, pub_key_did, did_key_prefix)) return error.InvalidDidKey; const multibase_key = pub_key_did[did_key_prefix.len..];
// decode message and signature const message = try base64StdDecode(allocator, message_b64); defer allocator.free(message);
const sig_bytes = base64StdDecode(allocator, sig_b64) catch |err| { // DER-encoded sigs may fail to decode at expected length — that's fine for invalid if (!valid) { tested += 1; continue; } return err; }; defer allocator.free(sig_bytes);
// decode public key from multibase+multicodec (did:key format) const key_bytes = try multibase.decode(allocator, multibase_key); defer allocator.free(key_bytes);
const parsed_key = try multicodec.parsePublicKey(key_bytes);
// verify signature const verify_result = if (std.mem.eql(u8, algorithm, "ES256K")) jwt.verifySecp256k1(message, sig_bytes, parsed_key.raw) else if (std.mem.eql(u8, algorithm, "ES256")) jwt.verifyP256(message, sig_bytes, parsed_key.raw) else error.UnsupportedAlgorithm;
if (valid) { verify_result catch |err| { std.debug.print("FAIL: expected valid signature but got {s}: {s}\n", .{ @errorName(err), comment }); return error.ExpectedValidSignature; }; } else { if (verify_result) |_| { std.debug.print("FAIL: expected invalid signature but verified OK: {s}\n", .{comment}); return error.ExpectedInvalidSignature; } else |_| {} }
tested += 1; }
// should have tested all 6 fixtures try std.testing.expect(tested == fixtures.len);}
// === tier 2b: did:key derivation ===
test "interop: did:key derivation K256" { const allocator = std.testing.allocator;
const fixture_json = @embedFile("w3c_didkey_K256"); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); defer parsed.deinit();
const fixtures = parsed.value.array.items; var tested: usize = 0;
for (fixtures) |fixture| { const obj = fixture.object; const hex_str = obj.get("privateKeyBytesHex").?.string; const expected_did = obj.get("publicDidKey").?.string;
var sk_bytes: [32]u8 = undefined; _ = std.fmt.hexToBytes(&sk_bytes, hex_str) catch return error.InvalidHex;
const kp = try Keypair.fromSecretKey(.secp256k1, sk_bytes); const actual_did = try kp.did(allocator); defer allocator.free(actual_did);
if (!std.mem.eql(u8, actual_did, expected_did)) { std.debug.print("FAIL K256: expected {s}, got {s}\n", .{ expected_did, actual_did }); return error.DidKeyMismatch; } tested += 1; }
try std.testing.expectEqual(@as(usize, 5), tested);}
test "interop: did:key derivation P256" { const allocator = std.testing.allocator;
const fixture_json = @embedFile("w3c_didkey_P256"); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); defer parsed.deinit();
const fixtures = parsed.value.array.items; var tested: usize = 0;
for (fixtures) |fixture| { const obj = fixture.object; const b58_str = obj.get("privateKeyBytesBase58").?.string; const expected_did = obj.get("publicDidKey").?.string;
// raw base58 (no multibase 'z' prefix) const decoded = try multibase.base58btc.decode(allocator, b58_str); defer allocator.free(decoded); if (decoded.len < 32) return error.KeyTooShort;
const kp = try Keypair.fromSecretKey(.p256, decoded[0..32].*); const actual_did = try kp.did(allocator); defer allocator.free(actual_did);
if (!std.mem.eql(u8, actual_did, expected_did)) { std.debug.print("FAIL P256: expected {s}, got {s}\n", .{ expected_did, actual_did }); return error.DidKeyMismatch; } tested += 1; }
try std.testing.expectEqual(@as(usize, 1), tested);}
// === tier 2c: data model round-trip ===
/// convert AT Protocol JSON to CBOR value/// handles $link (CID) and $bytes (byte string) special typesfn jsonToCbor(allocator: std.mem.Allocator, json: std.json.Value) !cbor.Value { switch (json) { .object => |obj| { // check for $link → CID if (obj.get("$link")) |link_val| { const link_str = switch (link_val) { .string => |s| s, else => return error.InvalidLink, }; // bafyrei... is base32lower multibase (without 'b' prefix in the $link value, // but CID strings in AT Protocol use the full multibase-prefixed form) // actually the fixture CIDs start with "bafyrei" which is base32lower with 'b' prefix const raw = try multibase.base32lower.decode(allocator, link_str[1..]); return .{ .cid = .{ .raw = raw } }; } // check for $bytes → byte string if (obj.get("$bytes")) |bytes_val| { const b64_str = switch (bytes_val) { .string => |s| s, else => return error.InvalidBytes, }; const decoded = try base64StdDecode(allocator, b64_str); return .{ .bytes = decoded }; } // regular object → map const entries = try allocator.alloc(cbor.Value.MapEntry, obj.count()); var i: usize = 0; var it = obj.iterator(); while (it.next()) |kv| { entries[i] = .{ .key = kv.key_ptr.*, .value = try jsonToCbor(allocator, kv.value_ptr.*), }; i += 1; } return .{ .map = entries }; }, .array => |arr| { const items = try allocator.alloc(cbor.Value, arr.items.len); for (arr.items, 0..) |item, i| { items[i] = try jsonToCbor(allocator, item); } return .{ .array = items }; }, .string => |s| return .{ .text = s }, .integer => |n| { if (n >= 0) return .{ .unsigned = @intCast(n) }; return .{ .negative = n }; }, .float => |f| { // DAG-CBOR has no floats; coerce integer-valued floats const int_val: i64 = @intFromFloat(f); if (@as(f64, @floatFromInt(int_val)) != f) return error.UnsupportedFloat; if (int_val >= 0) return .{ .unsigned = @intCast(int_val) }; return .{ .negative = int_val }; }, .null => return .null, .bool => |b| return .{ .boolean = b }, .number_string => return error.UnsupportedNumberString, }}
test "interop: data model fixtures" { const allocator = std.testing.allocator;
const fixture_json = @embedFile("data_model_fixtures"); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); defer parsed.deinit();
const fixtures = parsed.value.array.items; var tested: usize = 0;
for (fixtures) |fixture| { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator();
const obj = fixture.object; const json_val = obj.get("json").?; const expected_cbor_b64 = obj.get("cbor_base64").?.string; const expected_cid_str = obj.get("cid").?.string;
// convert JSON → CBOR value → encoded bytes const cbor_val = try jsonToCbor(a, json_val); const encoded = try cbor.encodeAlloc(a, cbor_val);
// compare encoded bytes with expected const expected_bytes = try base64StdDecode(a, expected_cbor_b64); if (!std.mem.eql(u8, encoded, expected_bytes)) { std.debug.print("FAIL data model: CBOR encoding mismatch for fixture {d}\n", .{tested}); std.debug.print(" expected ({d} bytes): ", .{expected_bytes.len}); for (expected_bytes) |b| std.debug.print("{x:0>2}", .{b}); std.debug.print("\n actual ({d} bytes): ", .{encoded.len}); for (encoded) |b| std.debug.print("{x:0>2}", .{b}); std.debug.print("\n", .{}); return error.CborEncodingMismatch; }
// compute CID and compare const cid = try cbor.Cid.forDagCbor(a, encoded); // format as base32lower multibase string: "b" + base32lower(raw) const cid_str = try multibase.base32lower.encode(a, cid.raw); if (!std.mem.eql(u8, cid_str, expected_cid_str)) { std.debug.print("FAIL data model: CID mismatch for fixture {d}\n", .{tested}); std.debug.print(" expected: {s}\n actual: {s}\n", .{ expected_cid_str, cid_str }); return error.CidMismatch; }
tested += 1; }
try std.testing.expectEqual(@as(usize, 3), tested);}
// === tier 3: MST ===
test "interop: mst key heights" { const allocator = std.testing.allocator;
const fixture_json = @embedFile("mst_key_heights"); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); defer parsed.deinit();
const fixtures = parsed.value.array.items; var tested: usize = 0;
for (fixtures) |fixture| { const obj = fixture.object; const key = obj.get("key").?.string; const expected_height: u32 = @intCast(obj.get("height").?.integer);
const actual = mst.keyHeight(key); if (actual != expected_height) { std.debug.print("FAIL: key '{s}': expected height {d}, got {d}\n", .{ key, expected_height, actual }); return error.WrongHeight; } tested += 1; }
try std.testing.expect(tested > 0);}
test "interop: mst common prefix" { const allocator = std.testing.allocator;
const fixture_json = @embedFile("common_prefix"); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); defer parsed.deinit();
const fixtures = parsed.value.array.items; var tested: usize = 0;
for (fixtures) |fixture| { const obj = fixture.object; const left = obj.get("left").?.string; const right = obj.get("right").?.string; const expected_len: usize = @intCast(obj.get("len").?.integer);
const actual = mst.commonPrefixLen(left, right); if (actual != expected_len) { std.debug.print("FAIL: commonPrefixLen('{s}', '{s}'): expected {d}, got {d}\n", .{ left, right, expected_len, actual }); return error.WrongPrefixLen; } tested += 1; }
try std.testing.expect(tested == 13);}
test "interop: mst commit proofs" { const allocator = std.testing.allocator;
const fixture_json = @embedFile("commit_proofs"); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); defer parsed.deinit();
const fixtures = parsed.value.array.items; var tested: usize = 0;
for (fixtures) |fixture| { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator();
const obj = fixture.object; const comment = if (obj.get("comment")) |v| switch (v) { .string => |s| s, else => "?", } else "?";
// parse leaf value CID const leaf_value_str = obj.get("leafValue").?.string; const leaf_cid = try mst.parseCidString(a, leaf_value_str);
// build initial tree from keys var tree = mst.Mst.init(a); const keys = obj.get("keys").?.array.items; for (keys) |key_val| { try tree.put(key_val.string, leaf_cid); }
// verify root before commit const root_before_str = obj.get("rootBeforeCommit").?.string; const expected_before = try mst.parseCidString(a, root_before_str);
const actual_before = try tree.rootCid(); if (!std.mem.eql(u8, actual_before.raw, expected_before.raw)) { std.debug.print("FAIL [{s}]: rootBeforeCommit mismatch\n", .{comment}); std.debug.print(" expected: {s}\n", .{root_before_str}); // print hex for debugging std.debug.print(" expected raw ({d}): ", .{expected_before.raw.len}); for (expected_before.raw) |b| std.debug.print("{x:0>2}", .{b}); std.debug.print("\n actual raw ({d}): ", .{actual_before.raw.len}); for (actual_before.raw) |b| std.debug.print("{x:0>2}", .{b}); std.debug.print("\n", .{}); return error.RootBeforeMismatch; }
// apply adds const adds = obj.get("adds").?.array.items; for (adds) |add_val| { try tree.put(add_val.string, leaf_cid); }
// apply dels const dels = obj.get("dels").?.array.items; for (dels) |del_val| { try tree.delete(del_val.string); }
// verify root after commit const root_after_str = obj.get("rootAfterCommit").?.string; const expected_after = try mst.parseCidString(a, root_after_str);
const actual_after = try tree.rootCid(); if (!std.mem.eql(u8, actual_after.raw, expected_after.raw)) { std.debug.print("FAIL [{s}]: rootAfterCommit mismatch\n", .{comment}); std.debug.print(" expected: {s}\n", .{root_after_str}); std.debug.print(" expected raw ({d}): ", .{expected_after.raw.len}); for (expected_after.raw) |b| std.debug.print("{x:0>2}", .{b}); std.debug.print("\n actual raw ({d}): ", .{actual_after.raw.len}); for (actual_after.raw) |b| std.debug.print("{x:0>2}", .{b}); std.debug.print("\n", .{}); return error.RootAfterMismatch; }
tested += 1; }
try std.testing.expect(tested == 6);}