From b7dcec71207371806dc34edfe3f4b750cf0abdf7 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Fri, 4 Sep 2026 21:54:43 -0500 Subject: [PATCH] space: accept unpadded base64 in $bytes the data model says `=` padding is optional; the reference PDS sends notifyWrite hashes unpadded, and every one was refused with "Invalid hash", so a remote member's repo never reached listRepos on a zds-hosted space. both $bytes decoders (space wire fields, record JSON) now strip padding and decode with the no-pad codec. Co-Authored-By: Claude Fable 5.1 --- src/atproto/space.zig | 17 ++++++++++++++--- src/internal/cbor_json.zig | 11 +++++++++++ src/storage/store.zig | 3 +-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/atproto/space.zig b/src/atproto/space.zig index c9b8c5b..8851128 100644 --- a/src/atproto/space.zig +++ b/src/atproto/space.zig @@ -10,6 +10,7 @@ const clock = @import("../core/clock.zig"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const http_api = @import("../http/api.zig"); +const cbor_json = @import("../internal/cbor_json.zig"); const permissioned = @import("../internal/permissioned_data.zig"); const client_attestation = @import("../internal/client_attestation.zig"); const dpop = @import("../internal/dpop.zig"); @@ -1650,9 +1651,7 @@ fn writeAtJsonBytes(writer: *std.Io.Writer, allocator: std.mem.Allocator, bytes: fn parseAtJsonBytes(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { const encoded = zat.json.getString(value, "$bytes") orelse return error.InvalidBytes; - const bytes = try allocator.alloc(u8, try std.base64.standard.Decoder.calcSizeForSlice(encoded)); - try std.base64.standard.Decoder.decode(bytes, encoded); - return bytes; + return cbor_json.decodeAtBase64(allocator, encoded); } fn jsonField(value: std.json.Value, key: []const u8) ?std.json.Value { @@ -1786,6 +1785,18 @@ test "parses permissioned space uris" { try std.testing.expectEqualStrings("self", parsed.skey); } +test "AT JSON bytes decode with and without padding" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + const expected = [_]u8{ 1, 2, 3, 4 }; + for ([_][]const u8{ "{\"$bytes\":\"AQIDBA==\"}", "{\"$bytes\":\"AQIDBA\"}" }) |text| { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); + defer parsed.deinit(); + try std.testing.expectEqualSlices(u8, &expected, try parseAtJsonBytes(allocator, parsed.value)); + } +} + test "permissioned wire hashes use AT JSON bytes" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); diff --git a/src/internal/cbor_json.zig b/src/internal/cbor_json.zig index d768986..60e0809 100644 --- a/src/internal/cbor_json.zig +++ b/src/internal/cbor_json.zig @@ -1,6 +1,17 @@ const std = @import("std"); const zat = @import("zat"); +/// the data model's `$bytes` is RFC 4648 base64 with padding optional; the reference PDS +/// sends it unpadded, so both forms must decode. +pub fn decodeAtBase64(allocator: std.mem.Allocator, encoded: []const u8) ![]const u8 { + var end = encoded.len; + while (end > 0 and encoded[end - 1] == '=') end -= 1; + const trimmed = encoded[0..end]; + const bytes = try allocator.alloc(u8, try std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed)); + try std.base64.standard_no_pad.Decoder.decode(bytes, trimmed); + return bytes; +} + pub fn writeAlloc(allocator: std.mem.Allocator, value: zat.cbor.Value) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); diff --git a/src/storage/store.zig b/src/storage/store.zig index 7ec83fb..8d0a627 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -5974,8 +5974,7 @@ fn jsonToDagCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor. }; if (object.get("$bytes")) |bytes_value| switch (bytes_value) { .string => |encoded| { - const bytes = try allocator.alloc(u8, std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return Error.InvalidDagCbor); - try std.base64.standard.Decoder.decode(bytes, encoded); + const bytes = cbor_json.decodeAtBase64(allocator, encoded) catch return Error.InvalidDagCbor; break :blk .{ .bytes = bytes }; }, else => {}, -- 2.51.2