atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add atproto interop tests for syntax, crypto, and MST

Wire up official bluesky-social/atproto-interop-tests as a lazy build
dependency. Tests are embedded at comptime and only fetched when running
zig build test.

Tier 1 — syntax validation for Tid, Did, Handle, Nsid, Rkey, AtUri
against valid/invalid fixture files.

Tier 2 — crypto signature verification against 6 test vectors covering
ES256/ES256K with valid low-S, invalid high-S, and invalid DER-encoded
signatures.

Tier 3 — MST key height computation (SHA-256 leading zero bits / 2)
against 9 test vectors.

Bumps version to 0.1.8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz a91b7324 f2d7202d

+317 -1
+29
build.zig
··· 19 19 }); 20 20 21 21 const tests = b.addTest(.{ .root_module = mod }); 22 + 23 + // add interop test fixtures (lazy — only fetched when running tests) 24 + if (b.lazyDependency("atproto-interop-tests", .{})) |interop| { 25 + const interop_files = .{ 26 + // syntax fixtures 27 + .{ "tid_syntax_valid", "syntax/tid_syntax_valid.txt" }, 28 + .{ "tid_syntax_invalid", "syntax/tid_syntax_invalid.txt" }, 29 + .{ "did_syntax_valid", "syntax/did_syntax_valid.txt" }, 30 + .{ "did_syntax_invalid", "syntax/did_syntax_invalid.txt" }, 31 + .{ "handle_syntax_valid", "syntax/handle_syntax_valid.txt" }, 32 + .{ "handle_syntax_invalid", "syntax/handle_syntax_invalid.txt" }, 33 + .{ "nsid_syntax_valid", "syntax/nsid_syntax_valid.txt" }, 34 + .{ "nsid_syntax_invalid", "syntax/nsid_syntax_invalid.txt" }, 35 + .{ "recordkey_syntax_valid", "syntax/recordkey_syntax_valid.txt" }, 36 + .{ "recordkey_syntax_invalid", "syntax/recordkey_syntax_invalid.txt" }, 37 + .{ "aturi_syntax_valid", "syntax/aturi_syntax_valid.txt" }, 38 + .{ "aturi_syntax_invalid", "syntax/aturi_syntax_invalid.txt" }, 39 + // crypto fixtures 40 + .{ "signature_fixtures", "crypto/signature-fixtures.json" }, 41 + // mst fixtures 42 + .{ "mst_key_heights", "mst/key_heights.json" }, 43 + }; 44 + inline for (interop_files) |entry| { 45 + tests.root_module.addAnonymousImport(entry[0], .{ 46 + .root_source_file = interop.path(entry[1]), 47 + }); 48 + } 49 + } 50 + 22 51 const run_tests = b.addRunArtifact(tests); 23 52 24 53 const test_step = b.step("test", "run unit tests");
+6 -1
build.zig.zon
··· 1 1 .{ 2 2 .name = .zat, 3 - .version = "0.1.7", 3 + .version = "0.1.8", 4 4 .fingerprint = 0x8da9db57ee82fbe4, 5 5 .minimum_zig_version = "0.15.0", 6 6 .dependencies = .{ 7 7 .websocket = .{ 8 8 .url = "https://github.com/karlseguin/websocket.zig/archive/97fefafa59cc78ce177cff540b8685cd7f699276.tar.gz", 9 9 .hash = "websocket-0.1.0-ZPISdRlzAwBB_Bz2UMMqxYqF6YEVTIBoFsbzwPUJTHIc", 10 + }, 11 + .@"atproto-interop-tests" = .{ 12 + .url = "https://github.com/bluesky-social/atproto-interop-tests/archive/35bb5638ab1e5ce71fb88a0c95953fc557ef1925.tar.gz", 13 + .hash = "N-V-__8AAIp5AQCe4JjGmPl8CplTkCis8PF1qvn7QX6GwDfu", 14 + .lazy = true, 10 15 }, 11 16 }, 12 17 .paths = .{
+275
src/internal/interop_tests.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 + 5 + const std = @import("std"); 6 + 7 + // types under test 8 + const Tid = @import("tid.zig").Tid; 9 + const Did = @import("did.zig").Did; 10 + const Handle = @import("handle.zig").Handle; 11 + const Nsid = @import("nsid.zig").Nsid; 12 + const Rkey = @import("rkey.zig").Rkey; 13 + const AtUri = @import("at_uri.zig").AtUri; 14 + 15 + // crypto 16 + const jwt = @import("jwt.zig"); 17 + const multibase = @import("multibase.zig"); 18 + const multicodec = @import("multicodec.zig"); 19 + 20 + // === helpers === 21 + 22 + fn LineIterator(comptime sentinel: ?u8) type { 23 + return struct { 24 + inner: std.mem.SplitIterator(u8, .scalar), 25 + 26 + const Self = @This(); 27 + 28 + fn init(data: []const u8) Self { 29 + // strip trailing sentinel if present (some files end with \n) 30 + const trimmed = if (sentinel) |s| 31 + if (data.len > 0 and data[data.len - 1] == s) data[0 .. data.len - 1] else data 32 + else 33 + data; 34 + return .{ .inner = std.mem.splitScalar(u8, trimmed, '\n') }; 35 + } 36 + 37 + fn next(self: *Self) ?[]const u8 { 38 + while (self.inner.next()) |line| { 39 + // skip blank lines and comments 40 + if (line.len == 0) continue; 41 + if (line[0] == '#') continue; 42 + // strip trailing \r for windows line endings 43 + const trimmed = if (line.len > 0 and line[line.len - 1] == '\r') 44 + line[0 .. line.len - 1] 45 + else 46 + line; 47 + if (trimmed.len == 0) continue; 48 + return trimmed; 49 + } 50 + return null; 51 + } 52 + }; 53 + } 54 + 55 + fn testLinesSentinel(comptime data: [:0]const u8) LineIterator(0) { 56 + return LineIterator(0).init(data); 57 + } 58 + 59 + /// run syntax validation tests for a parser type 60 + fn syntaxTest( 61 + comptime valid_data: [:0]const u8, 62 + comptime invalid_data: [:0]const u8, 63 + comptime parseFn: anytype, 64 + ) !void { 65 + // test valid lines 66 + var valid_lines = testLinesSentinel(valid_data); 67 + var valid_count: usize = 0; 68 + while (valid_lines.next()) |line| { 69 + if (parseFn(line) == null) { 70 + std.debug.print("FAIL: expected valid, got null for: '{s}'\n", .{line}); 71 + return error.ExpectedValid; 72 + } 73 + valid_count += 1; 74 + } 75 + if (valid_count == 0) return error.NoTestCases; 76 + 77 + // test invalid lines 78 + var invalid_lines = testLinesSentinel(invalid_data); 79 + var invalid_count: usize = 0; 80 + while (invalid_lines.next()) |line| { 81 + if (parseFn(line) != null) { 82 + std.debug.print("FAIL: expected null, got valid for: '{s}'\n", .{line}); 83 + return error.ExpectedInvalid; 84 + } 85 + invalid_count += 1; 86 + } 87 + if (invalid_count == 0) return error.NoTestCases; 88 + } 89 + 90 + // === tier 1: syntax validation === 91 + 92 + test "interop: tid syntax" { 93 + try syntaxTest( 94 + @embedFile("tid_syntax_valid"), 95 + @embedFile("tid_syntax_invalid"), 96 + Tid.parse, 97 + ); 98 + } 99 + 100 + test "interop: did syntax" { 101 + try syntaxTest( 102 + @embedFile("did_syntax_valid"), 103 + @embedFile("did_syntax_invalid"), 104 + Did.parse, 105 + ); 106 + } 107 + 108 + test "interop: handle syntax" { 109 + try syntaxTest( 110 + @embedFile("handle_syntax_valid"), 111 + @embedFile("handle_syntax_invalid"), 112 + Handle.parse, 113 + ); 114 + } 115 + 116 + test "interop: nsid syntax" { 117 + try syntaxTest( 118 + @embedFile("nsid_syntax_valid"), 119 + @embedFile("nsid_syntax_invalid"), 120 + Nsid.parse, 121 + ); 122 + } 123 + 124 + test "interop: rkey syntax" { 125 + try syntaxTest( 126 + @embedFile("recordkey_syntax_valid"), 127 + @embedFile("recordkey_syntax_invalid"), 128 + Rkey.parse, 129 + ); 130 + } 131 + 132 + test "interop: aturi syntax" { 133 + try syntaxTest( 134 + @embedFile("aturi_syntax_valid"), 135 + @embedFile("aturi_syntax_invalid"), 136 + AtUri.parse, 137 + ); 138 + } 139 + 140 + // === tier 2: crypto signature verification === 141 + 142 + fn base64StdDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 143 + // try standard (padded) first, fall back to no-pad 144 + const decoder = if (input.len > 0 and input[input.len - 1] == '=') 145 + &std.base64.standard.Decoder 146 + else 147 + &std.base64.standard_no_pad.Decoder; 148 + 149 + const size = decoder.calcSizeForSlice(input) catch return error.InvalidBase64; 150 + const output = try allocator.alloc(u8, size); 151 + errdefer allocator.free(output); 152 + decoder.decode(output, input) catch return error.InvalidBase64; 153 + return output; 154 + } 155 + 156 + test "interop: crypto signature verification" { 157 + const allocator = std.testing.allocator; 158 + 159 + const fixture_json = @embedFile("signature_fixtures"); 160 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); 161 + defer parsed.deinit(); 162 + 163 + const fixtures = parsed.value.array.items; 164 + var tested: usize = 0; 165 + 166 + for (fixtures) |fixture| { 167 + const obj = fixture.object; 168 + 169 + const comment = if (obj.get("comment")) |v| switch (v) { 170 + .string => |s| s, 171 + else => "?", 172 + } else "?"; 173 + 174 + const message_b64 = obj.get("messageBase64").?.string; 175 + const algorithm = obj.get("algorithm").?.string; 176 + const pub_key_did = obj.get("publicKeyDid").?.string; 177 + const sig_b64 = obj.get("signatureBase64").?.string; 178 + const valid = obj.get("validSignature").?.bool; 179 + 180 + // extract multibase key from did:key (strip "did:key:" prefix) 181 + const did_key_prefix = "did:key:"; 182 + if (!std.mem.startsWith(u8, pub_key_did, did_key_prefix)) return error.InvalidDidKey; 183 + const multibase_key = pub_key_did[did_key_prefix.len..]; 184 + 185 + // decode message and signature 186 + const message = try base64StdDecode(allocator, message_b64); 187 + defer allocator.free(message); 188 + 189 + const sig_bytes = base64StdDecode(allocator, sig_b64) catch |err| { 190 + // DER-encoded sigs may fail to decode at expected length — that's fine for invalid 191 + if (!valid) { 192 + tested += 1; 193 + continue; 194 + } 195 + return err; 196 + }; 197 + defer allocator.free(sig_bytes); 198 + 199 + // decode public key from multibase+multicodec (did:key format) 200 + const key_bytes = try multibase.decode(allocator, multibase_key); 201 + defer allocator.free(key_bytes); 202 + 203 + const parsed_key = try multicodec.parsePublicKey(key_bytes); 204 + 205 + // verify signature 206 + const verify_result = if (std.mem.eql(u8, algorithm, "ES256K")) 207 + jwt.verifySecp256k1(message, sig_bytes, parsed_key.raw) 208 + else if (std.mem.eql(u8, algorithm, "ES256")) 209 + jwt.verifyP256(message, sig_bytes, parsed_key.raw) 210 + else 211 + error.UnsupportedAlgorithm; 212 + 213 + if (valid) { 214 + verify_result catch |err| { 215 + std.debug.print("FAIL: expected valid signature but got {s}: {s}\n", .{ @errorName(err), comment }); 216 + return error.ExpectedValidSignature; 217 + }; 218 + } else { 219 + if (verify_result) |_| { 220 + std.debug.print("FAIL: expected invalid signature but verified OK: {s}\n", .{comment}); 221 + return error.ExpectedInvalidSignature; 222 + } else |_| {} 223 + } 224 + 225 + tested += 1; 226 + } 227 + 228 + // should have tested all 6 fixtures 229 + try std.testing.expect(tested == fixtures.len); 230 + } 231 + 232 + // === tier 3: MST key heights === 233 + 234 + /// compute MST tree depth for a record key. 235 + /// depth = count leading zero bits in SHA-256(key), divided by 2, rounded down. 236 + fn mstKeyHeight(key: []const u8) u32 { 237 + var digest: [32]u8 = undefined; 238 + std.crypto.hash.sha2.Sha256.hash(key, &digest, .{}); 239 + var leading_zeros: u32 = 0; 240 + for (digest) |byte| { 241 + if (byte == 0) { 242 + leading_zeros += 8; 243 + } else { 244 + leading_zeros += @clz(byte); 245 + break; 246 + } 247 + } 248 + return leading_zeros / 2; 249 + } 250 + 251 + test "interop: mst key heights" { 252 + const allocator = std.testing.allocator; 253 + 254 + const fixture_json = @embedFile("mst_key_heights"); 255 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); 256 + defer parsed.deinit(); 257 + 258 + const fixtures = parsed.value.array.items; 259 + var tested: usize = 0; 260 + 261 + for (fixtures) |fixture| { 262 + const obj = fixture.object; 263 + const key = obj.get("key").?.string; 264 + const expected_height: u32 = @intCast(obj.get("height").?.integer); 265 + 266 + const actual = mstKeyHeight(key); 267 + if (actual != expected_height) { 268 + std.debug.print("FAIL: key '{s}': expected height {d}, got {d}\n", .{ key, expected_height, actual }); 269 + return error.WrongHeight; 270 + } 271 + tested += 1; 272 + } 273 + 274 + try std.testing.expect(tested > 0); 275 + }
+7
src/root.zig
··· 44 44 pub const firehose = @import("internal/firehose.zig"); 45 45 pub const FirehoseClient = firehose.FirehoseClient; 46 46 pub const FirehoseEvent = firehose.Event; 47 + 48 + // interop tests (test-only, references resolved by build.zig lazy dependency) 49 + comptime { 50 + if (@import("builtin").is_test) { 51 + _ = @import("internal/interop_tests.zig"); 52 + } 53 + }