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.

fix 6 issues from code review: overflow, empty CID, varint, CAR roots

Fixes from thorough code review:

- skipValue: arg.val * 2 overflow on crafted map headers (use std.math.mul)
- peekTypeAt: @intCast panic on huge map count (use std.math.cast)
- readUvarint: 10th byte silently truncated (reject byte > 1 at shift 63)
- readUvarint: use u7 shift to avoid saturation arithmetic
- Reject empty CIDs (just 0x00 prefix, no version/codec bytes) in both
the high-level decoder and the low-level readCidLink
- CAR reader: reject non-CID values in roots array (was silently skipping)
- MST loadFromBlocks: remove unnecessary 512-byte buffer copy for root key

Add 4 tests: varint 10th byte overflow/acceptance, empty CID rejection,
too-short CID rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

jcalabro 62e99128 f24148b2

+63 -16
+1 -1
src/internal/repo/car.zig
··· 87 87 for (root_values) |root_val| { 88 88 switch (root_val) { 89 89 .cid => |c| try roots.append(allocator, c), 90 - else => {}, 90 + else => return error.InvalidHeader, // roots must all be CID links 91 91 } 92 92 } 93 93 if (roots.items.len == 0) return error.InvalidHeader;
+16 -10
src/internal/repo/cbor.zig
··· 366 366 .bytes => |b| b, 367 367 else => return error.InvalidCid, 368 368 }; 369 - if (cid_bytes.len < 1 or cid_bytes[0] != 0x00) return error.InvalidCid; 369 + // CID byte string must have 0x00 identity multibase prefix + at least 370 + // version byte + codec byte (minimum 3 bytes total) 371 + if (cid_bytes.len < 3 or cid_bytes[0] != 0x00) return error.InvalidCid; 370 372 break :blk .{ .cid = .{ .raw = cid_bytes[1..] } }; // zero-cost: just reference the bytes 371 373 }, 372 374 .simple => unreachable, // handled above ··· 379 381 return .{ .raw = raw }; 380 382 } 381 383 382 - /// read an unsigned varint (LEB128). rejects varints longer than 10 bytes. 384 + /// read an unsigned varint (LEB128). rejects varints longer than 10 bytes 385 + /// and rejects overflow (10th byte must have value <= 1). 383 386 pub fn readUvarint(data: []const u8, pos: *usize) ?u64 { 384 387 var result: u64 = 0; 385 - var shift: u6 = 0; 386 - for (0..10) |_| { 388 + var shift: u7 = 0; 389 + for (0..10) |i| { 387 390 if (pos.* >= data.len) return null; 388 391 const byte = data[pos.*]; 389 392 pos.* += 1; 390 - result |= @as(u64, byte & 0x7f) << shift; 393 + // 10th byte (i=9, shift=63): only bit 0 can fit in u64 394 + if (i == 9 and byte > 1) return null; 395 + result |= @as(u64, byte & 0x7f) << @as(u6, @intCast(shift)); 391 396 if (byte & 0x80 == 0) return result; 392 - shift +|= 7; 397 + shift += 7; 393 398 } 394 399 return null; // varint too long 395 400 } ··· 711 716 // Read the inner byte string 712 717 const bytes_result = try readBytes(data, tag_arg.end); 713 718 const payload = bytes_result.val; 714 - // Must have at least the 0x00 prefix 715 - if (payload.len == 0 or payload[0] != 0x00) return error.InvalidCid; 719 + // Must have 0x00 prefix + at least version byte + codec byte (min 3 bytes) 720 + if (payload.len < 3 or payload[0] != 0x00) return error.InvalidCid; 716 721 return .{ .val = payload[1..], .end = bytes_result.end }; 717 722 } 718 723 ··· 756 761 // map: push key+value count (2 per entry) 757 762 if (arg.val > 0) { 758 763 if (depth >= max_stack) return error.MaxDepthExceeded; 759 - stack[depth] = arg.val * 2; 764 + stack[depth] = std.math.mul(u64, arg.val, 2) catch return error.Overflow; 760 765 depth += 1; 761 766 continue; 762 767 } ··· 796 801 var cur = map_header.end; 797 802 const count = map_header.val; 798 803 799 - for (0..@as(usize, @intCast(count))) |_| { 804 + const safe_count = std.math.cast(usize, count) orelse return null; 805 + for (0..safe_count) |_| { 800 806 // Read key — DAG-CBOR keys are always text strings 801 807 const key = readText(data, cur) catch return null; 802 808 cur = key.end;
+43
src/internal/repo/cbor_test.zig
··· 1168 1168 1169 1169 // === negative integer encode round-trip at min i64 === 1170 1170 1171 + // === readUvarint 10th byte overflow rejection === 1172 + 1173 + test "readUvarint rejects 10th byte with value > 1" { 1174 + // 9 continuation bytes (0x80) + 10th byte with value 2 (bit 1 set, would overflow u64) 1175 + const data = [_]u8{0x80} ** 9 ++ [_]u8{0x02}; 1176 + var pos: usize = 0; 1177 + try std.testing.expect(cbor.readUvarint(&data, &pos) == null); 1178 + } 1179 + 1180 + test "readUvarint accepts 10th byte with value 1 (max u64)" { 1181 + // 9 continuation bytes (0xff = 0x7f data + continuation) + 10th byte 0x01 1182 + // This encodes 2^63 + (lower 63 bits all set) = max u64 1183 + const data = [_]u8{0xff} ** 9 ++ [_]u8{0x01}; 1184 + var pos: usize = 0; 1185 + const val = cbor.readUvarint(&data, &pos); 1186 + try std.testing.expect(val != null); 1187 + try std.testing.expectEqual(std.math.maxInt(u64), val.?); 1188 + } 1189 + 1190 + // === CID minimum size === 1191 + 1192 + test "reject tag 42 with only 0x00 prefix (empty CID)" { 1193 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1194 + defer arena.deinit(); 1195 + // tag(42) + bytes([0x00]) — prefix present but no actual CID bytes 1196 + try std.testing.expectError(error.InvalidCid, cbor.decode(arena.allocator(), &.{ 1197 + 0xd8, 0x2a, // tag(42) 1198 + 0x41, 0x00, // bytes(1) with just the 0x00 prefix 1199 + })); 1200 + } 1201 + 1202 + test "reject tag 42 with only prefix + 1 byte (too short for CID)" { 1203 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1204 + defer arena.deinit(); 1205 + // tag(42) + bytes([0x00, 0x01]) — only 1 CID byte, need at least version + codec 1206 + try std.testing.expectError(error.InvalidCid, cbor.decode(arena.allocator(), &.{ 1207 + 0xd8, 0x2a, // tag(42) 1208 + 0x42, 0x00, 0x01, // bytes(2) — prefix + 1 byte 1209 + })); 1210 + } 1211 + 1212 + // === negative integer encode round-trip at min i64 === 1213 + 1171 1214 test "round-trip encode min i64" { 1172 1215 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1173 1216 defer arena.deinit();
+3 -5
src/internal/repo/mst.zig
··· 469 469 470 470 const root_node = try loadNodeFromData(allocator, repo_car, root_node_data); 471 471 472 - // root layer = key height of first entry 473 - var key_buf: [512]u8 = undefined; 474 - const first = root_node_data.entries[0]; 475 - @memcpy(key_buf[0..first.key_suffix.len], first.key_suffix); 476 - const root_layer = keyHeight(key_buf[0..first.key_suffix.len]); 472 + // root layer = key height of first entry (root entry has prefix_len=0, 473 + // so key_suffix IS the full key — no need to copy) 474 + const root_layer = keyHeight(root_node_data.entries[0].key_suffix); 477 475 478 476 return .{ 479 477 .allocator = allocator,