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 DAG-CBOR decoder strict spec compliance, add 98 tests ported from atmos

The decoder was silently accepting several classes of invalid DAG-CBOR:
non-minimal integer encodings, unsorted/duplicate map keys, arbitrary
CBOR tags, invalid UTF-8 in text strings, and had no nesting depth limit.

Fixes: non-minimal encoding rejection, trailing bytes detection, tag 42
restriction, map key ordering + uniqueness validation, UTF-8 validation,
max depth (128) guard, and early rejection of impossibly large allocation
claims. Also fixes existing test data that used unsorted map keys.

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

jcalabro 01dc9a85 dfd015c4

+1121 -29
+2 -2
src/internal/repo/car.zig
··· 259 259 // header: DAG-CBOR {"version": 1, "roots": []} 260 260 const header_cbor = [_]u8{ 261 261 0xa2, // map(2) 262 - 0x67, 'v', 'e', 'r', 's', 'i', 'o', 'n', 0x01, // "version": 1 263 - 0x65, 'r', 'o', 'o', 't', 's', 0x80, // "roots": [] 262 + 0x65, 'r', 'o', 'o', 't', 's', 0x80, // "roots": [] (5 bytes, shorter) 263 + 0x67, 'v', 'e', 'r', 's', 'i', 'o', 'n', 0x01, // "version": 1 (7 bytes) 264 264 }; 265 265 266 266 // one block: CIDv1 (dag-cbor, sha2-256) + CBOR data
+65 -27
src/internal/repo/cbor.zig
··· 228 228 ReservedAdditionalInfo, 229 229 Overflow, 230 230 OutOfMemory, 231 + NonMinimalEncoding, 232 + TrailingBytes, 233 + UnsupportedTag, 234 + UnsortedMapKeys, 235 + DuplicateMapKey, 236 + InvalidUtf8, 237 + MaxDepthExceeded, 231 238 }; 239 + 240 + /// maximum nesting depth for arrays/maps to prevent stack overflow 241 + pub const max_depth: usize = 128; 232 242 233 243 /// decode a single CBOR value from the front of `data`. 234 244 /// returns the value and the number of bytes consumed. 235 245 pub fn decode(allocator: Allocator, data: []const u8) DecodeError!struct { value: Value, consumed: usize } { 236 246 var pos: usize = 0; 237 - const value = try decodeAt(allocator, data, &pos); 247 + const value = try decodeAt(allocator, data, &pos, 0); 238 248 return .{ .value = value, .consumed = pos }; 239 249 } 240 250 241 - /// decode all bytes as a single CBOR value 251 + /// decode all bytes as a single CBOR value, rejecting trailing bytes 242 252 pub fn decodeAll(allocator: Allocator, data: []const u8) DecodeError!Value { 243 253 var pos: usize = 0; 244 - return try decodeAt(allocator, data, &pos); 254 + const value = try decodeAt(allocator, data, &pos, 0); 255 + if (pos != data.len) return error.TrailingBytes; 256 + return value; 245 257 } 246 258 247 - fn decodeAt(allocator: Allocator, data: []const u8, pos: *usize) DecodeError!Value { 259 + fn decodeAt(allocator: Allocator, data: []const u8, pos: *usize, depth: usize) DecodeError!Value { 248 260 if (pos.* >= data.len) return error.UnexpectedEof; 249 261 250 262 const initial = data[pos.*]; ··· 277 289 const end = pos.* + @as(usize, @intCast(len)); 278 290 if (end > data.len) return error.UnexpectedEof; 279 291 const text = data[pos.*..end]; 292 + if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8; 280 293 pos.* = end; 281 294 return .{ .text = text }; 282 295 }, 283 296 .array => { 297 + if (depth >= max_depth) return error.MaxDepthExceeded; 284 298 const count = try readArgument(data, pos, additional); 299 + // sanity check: each element is at least 1 byte 300 + if (count > data.len - pos.*) return error.UnexpectedEof; 285 301 const items = try allocator.alloc(Value, @intCast(count)); 286 302 for (items) |*item| { 287 - item.* = try decodeAt(allocator, data, pos); 303 + item.* = try decodeAt(allocator, data, pos, depth + 1); 288 304 } 289 305 return .{ .array = items }; 290 306 }, 291 307 .map => { 308 + if (depth >= max_depth) return error.MaxDepthExceeded; 292 309 const count = try readArgument(data, pos, additional); 310 + // sanity check: each entry is at least 2 bytes (key + value) 311 + if (count > (data.len - pos.*) / 2) return error.UnexpectedEof; 293 312 const entries = try allocator.alloc(Value.MapEntry, @intCast(count)); 294 - for (entries) |*entry| { 313 + for (entries, 0..) |*entry, i| { 295 314 // DAG-CBOR: map keys must be text strings — inline read to avoid 296 315 // a full decodeAt + Value union construction per key 297 316 if (pos.* >= data.len) return error.UnexpectedEof; ··· 302 321 const key_end = pos.* + @as(usize, @intCast(key_len)); 303 322 if (key_end > data.len) return error.UnexpectedEof; 304 323 entry.key = data[pos.*..key_end]; 324 + if (!std.unicode.utf8ValidateSlice(entry.key)) return error.InvalidUtf8; 305 325 pos.* = key_end; 306 - entry.value = try decodeAt(allocator, data, pos); 326 + 327 + // DAG-CBOR: keys must be sorted (shorter first, then lex) and unique 328 + if (i > 0) { 329 + const prev = entries[i - 1].key; 330 + if (prev.len < entry.key.len) { 331 + // ok — shorter key first 332 + } else if (prev.len == entry.key.len) { 333 + switch (std.mem.order(u8, prev, entry.key)) { 334 + .lt => {}, // ok — lex order 335 + .eq => return error.DuplicateMapKey, 336 + .gt => return error.UnsortedMapKeys, 337 + } 338 + } else { 339 + return error.UnsortedMapKeys; 340 + } 341 + } 342 + 343 + entry.value = try decodeAt(allocator, data, pos, depth + 1); 307 344 } 308 345 return .{ .map = entries }; 309 346 }, 310 347 .tag => { 311 348 const tag_num = try readArgument(data, pos, additional); 312 - if (tag_num == 42) { 313 - // CID link — content is a byte string with 0x00 prefix 314 - const content = try decodeAt(allocator, data, pos); 315 - const cid_bytes = switch (content) { 316 - .bytes => |b| b, 317 - else => return error.InvalidCid, 318 - }; 319 - if (cid_bytes.len < 1 or cid_bytes[0] != 0x00) return error.InvalidCid; 320 - return .{ .cid = .{ .raw = cid_bytes[1..] } }; // zero-cost: just reference the bytes 321 - } 322 - // generic tag — allocate content on heap 323 - const content_ptr = try allocator.create(Value); 324 - content_ptr.* = try decodeAt(allocator, data, pos); 325 - return .{ .tag = .{ .number = tag_num, .content = content_ptr } }; 349 + if (tag_num != 42) return error.UnsupportedTag; // DAG-CBOR only allows tag 42 (CID) 350 + // CID link — content is a byte string with 0x00 prefix 351 + const content = try decodeAt(allocator, data, pos, depth); 352 + const cid_bytes = switch (content) { 353 + .bytes => |b| b, 354 + else => return error.InvalidCid, 355 + }; 356 + if (cid_bytes.len < 1 or cid_bytes[0] != 0x00) return error.InvalidCid; 357 + return .{ .cid = .{ .raw = cid_bytes[1..] } }; // zero-cost: just reference the bytes 326 358 }, 327 359 .simple => { 328 360 return switch (additional) { ··· 337 369 }; 338 370 } 339 371 340 - /// read the argument value from additional info + following bytes 372 + /// read the argument value from additional info + following bytes. 373 + /// enforces DAG-CBOR shortest-form encoding: rejects values that could 374 + /// have been encoded with fewer bytes. 341 375 fn readArgument(data: []const u8, pos: *usize, additional: u5) DecodeError!u64 { 342 376 return switch (additional) { 343 377 0...23 => @as(u64, additional), ··· 345 379 if (pos.* >= data.len) return error.UnexpectedEof; 346 380 const val = data[pos.*]; 347 381 pos.* += 1; 382 + if (val < 24) return error.NonMinimalEncoding; 348 383 return @as(u64, val); 349 384 }, 350 385 25 => { // 2-byte big-endian 351 386 if (pos.* + 2 > data.len) return error.UnexpectedEof; 352 387 const val = std.mem.readInt(u16, data[pos.*..][0..2], .big); 353 388 pos.* += 2; 389 + if (val <= 0xff) return error.NonMinimalEncoding; 354 390 return @as(u64, val); 355 391 }, 356 392 26 => { // 4-byte big-endian 357 393 if (pos.* + 4 > data.len) return error.UnexpectedEof; 358 394 const val = std.mem.readInt(u32, data[pos.*..][0..4], .big); 359 395 pos.* += 4; 396 + if (val <= 0xffff) return error.NonMinimalEncoding; 360 397 return @as(u64, val); 361 398 }, 362 399 27 => { // 8-byte big-endian 363 400 if (pos.* + 8 > data.len) return error.UnexpectedEof; 364 401 const val = std.mem.readInt(u64, data[pos.*..][0..8], .big); 365 402 pos.* += 8; 403 + if (val <= 0xffffffff) return error.NonMinimalEncoding; 366 404 return val; 367 405 }, 368 406 28, 29, 30 => error.ReservedAdditionalInfo, ··· 601 639 defer arena.deinit(); 602 640 const alloc = arena.allocator(); 603 641 604 - // {"op": 1, "t": "#commit"} 642 + // {"t": "#commit", "op": 1} — sorted by key length (1 < 2) 605 643 const result = try decode(alloc, &.{ 606 644 0xa2, // map(2) 607 - 0x62, 'o', 'p', 0x01, // "op": 1 608 645 0x61, 't', 0x67, '#', 'c', 'o', 'm', 'm', 'i', 't', // "t": "#commit" 646 + 0x62, 'o', 'p', 0x01, // "op": 1 609 647 }); 610 648 const val = result.value; 611 649 try std.testing.expectEqual(@as(u64, 1), val.get("op").?.unsigned); ··· 643 681 644 682 const result = try decode(alloc, &.{ 645 683 0xa3, // map(3) 646 - 0x64, 'n', 'a', 'm', 'e', 0x65, 'a', 'l', 'i', 'c', 'e', // "name": "alice" 647 - 0x63, 'a', 'g', 'e', 0x18, 30, // "age": 30 648 - 0x66, 'a', 'c', 't', 'i', 'v', 'e', 0xf5, // "active": true 684 + 0x63, 'a', 'g', 'e', 0x18, 30, // "age": 30 (3 bytes, shortest) 685 + 0x64, 'n', 'a', 'm', 'e', 0x65, 'a', 'l', 'i', 'c', 'e', // "name": "alice" (4 bytes) 686 + 0x66, 'a', 'c', 't', 'i', 'v', 'e', 0xf5, // "active": true (6 bytes) 649 687 }); 650 688 const val = result.value; 651 689 try std.testing.expectEqualStrings("alice", val.getString("name").?);
+1053
src/internal/repo/cbor_test.zig
··· 1 + //! additional DAG-CBOR codec tests ported from atmos (Go implementation). 2 + //! 3 + //! focuses on spec compliance, edge cases, and error paths not covered 4 + //! by the inline tests in cbor.zig. 5 + 6 + const std = @import("std"); 7 + const cbor = @import("cbor.zig"); 8 + const Value = cbor.Value; 9 + const Cid = cbor.Cid; 10 + 11 + // === non-minimal encoding rejection === 12 + // 13 + // DAG-CBOR requires shortest-form encoding. values that fit in a smaller 14 + // representation must not be encoded with a larger one. 15 + 16 + test "reject non-minimal unsigned: 0 encoded as 1-byte" { 17 + // 0x18 0x00 = unsigned(0) with 1-byte additional, but 0 fits in additional field directly 18 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 19 + defer arena.deinit(); 20 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x18, 0x00 })); 21 + } 22 + 23 + test "reject non-minimal unsigned: 23 encoded as 1-byte" { 24 + // 0x18 0x17 = unsigned(23) with 1-byte additional, but 23 fits in additional field 25 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 26 + defer arena.deinit(); 27 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x18, 0x17 })); 28 + } 29 + 30 + test "reject non-minimal unsigned: 255 encoded as 2-byte" { 31 + // 0x19 0x00 0xff = unsigned(255) with 2-byte additional, but fits in 1-byte 32 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 33 + defer arena.deinit(); 34 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x19, 0x00, 0xff })); 35 + } 36 + 37 + test "reject non-minimal unsigned: 256 encoded as 4-byte" { 38 + // 0x1a 0x00 0x00 0x01 0x00 = unsigned(256) with 4-byte additional, but fits in 2-byte 39 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 40 + defer arena.deinit(); 41 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x1a, 0x00, 0x00, 0x01, 0x00 })); 42 + } 43 + 44 + test "reject non-minimal unsigned: 65535 encoded as 4-byte" { 45 + // 0x1a 0x00 0x00 0xff 0xff = unsigned(65535) with 4-byte, but fits in 2-byte 46 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 47 + defer arena.deinit(); 48 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x1a, 0x00, 0x00, 0xff, 0xff })); 49 + } 50 + 51 + test "reject non-minimal unsigned: 1 encoded as 8-byte" { 52 + // 0x1b 0x00..0x01 = unsigned(1) with 8-byte additional 53 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 54 + defer arena.deinit(); 55 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 })); 56 + } 57 + 58 + test "reject non-minimal negative: -1 encoded as 1-byte" { 59 + // 0x38 0x00 = negative(-1) with 1-byte additional, but -1 fits in additional field (0x20) 60 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 61 + defer arena.deinit(); 62 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x38, 0x00 })); 63 + } 64 + 65 + test "reject non-minimal negative: -24 encoded as 1-byte" { 66 + // 0x38 0x17 = negative(-24) with 1-byte additional, but fits in additional field (0x37) 67 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 68 + defer arena.deinit(); 69 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x38, 0x17 })); 70 + } 71 + 72 + test "reject non-minimal text string length: empty string as 1-byte length" { 73 + // 0x78 0x00 = text(0) with 1-byte length, but 0 fits in additional field (0x60) 74 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 75 + defer arena.deinit(); 76 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x78, 0x00 })); 77 + } 78 + 79 + test "reject non-minimal byte string length" { 80 + // 0x58 0x00 = bytes(0) with 1-byte length, but 0 fits in additional field (0x40) 81 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 82 + defer arena.deinit(); 83 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x58, 0x00 })); 84 + } 85 + 86 + test "reject non-minimal array length" { 87 + // 0x98 0x00 = array(0) with 1-byte length, but 0 fits in additional field (0x80) 88 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 89 + defer arena.deinit(); 90 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0x98, 0x00 })); 91 + } 92 + 93 + test "reject non-minimal map length" { 94 + // 0xb8 0x00 = map(0) with 1-byte length, but 0 fits in additional field (0xa0) 95 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 96 + defer arena.deinit(); 97 + try std.testing.expectError(error.NonMinimalEncoding, cbor.decode(arena.allocator(), &.{ 0xb8, 0x00 })); 98 + } 99 + 100 + // === trailing bytes rejection === 101 + 102 + test "decodeAll rejects trailing bytes" { 103 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 104 + defer arena.deinit(); 105 + // integer 1 followed by extra byte 0x02 106 + try std.testing.expectError(error.TrailingBytes, cbor.decodeAll(arena.allocator(), &.{ 0x01, 0x02 })); 107 + } 108 + 109 + // === tag restriction === 110 + // 111 + // DAG-CBOR only allows tag 42 (CID links). all other tags must be rejected. 112 + 113 + test "reject tag 0 (date/time)" { 114 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 115 + defer arena.deinit(); 116 + // 0xc0 0x60 = tag(0) wrapping empty text string 117 + try std.testing.expectError(error.UnsupportedTag, cbor.decode(arena.allocator(), &.{ 0xc0, 0x60 })); 118 + } 119 + 120 + test "reject tag 1 (epoch time)" { 121 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 122 + defer arena.deinit(); 123 + // 0xc1 0x01 = tag(1) wrapping integer 1 124 + try std.testing.expectError(error.UnsupportedTag, cbor.decode(arena.allocator(), &.{ 0xc1, 0x01 })); 125 + } 126 + 127 + test "reject tag 2 (positive bignum)" { 128 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 129 + defer arena.deinit(); 130 + // 0xc2 0x41 0x01 = tag(2) wrapping byte string [0x01] 131 + try std.testing.expectError(error.UnsupportedTag, cbor.decode(arena.allocator(), &.{ 0xc2, 0x41, 0x01 })); 132 + } 133 + 134 + test "reject tag 3 (negative bignum)" { 135 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 136 + defer arena.deinit(); 137 + try std.testing.expectError(error.UnsupportedTag, cbor.decode(arena.allocator(), &.{ 0xc3, 0x41, 0x01 })); 138 + } 139 + 140 + test "reject tag 55799 (self-describe CBOR)" { 141 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 142 + defer arena.deinit(); 143 + // 0xd9 0xd9 0xf7 0x01 = tag(55799) wrapping integer 1 144 + try std.testing.expectError(error.UnsupportedTag, cbor.decode(arena.allocator(), &.{ 0xd9, 0xd9, 0xf7, 0x01 })); 145 + } 146 + 147 + // === map key ordering validation === 148 + // 149 + // DAG-CBOR requires map keys sorted by byte length (shorter first), 150 + // then lexicographically. 151 + 152 + test "reject unsorted map keys: wrong lexicographic order" { 153 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 154 + defer arena.deinit(); 155 + // {"b": 1, "a": 2} — same length, wrong lex order 156 + try std.testing.expectError(error.UnsortedMapKeys, cbor.decode(arena.allocator(), &.{ 157 + 0xa2, // map(2) 158 + 0x61, 'b', 0x01, // "b": 1 159 + 0x61, 'a', 0x02, // "a": 2 (should come before "b") 160 + })); 161 + } 162 + 163 + test "reject unsorted map keys: longer key before shorter" { 164 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 165 + defer arena.deinit(); 166 + // {"bb": 1, "a": 2} — 2-char key before 1-char key 167 + try std.testing.expectError(error.UnsortedMapKeys, cbor.decode(arena.allocator(), &.{ 168 + 0xa2, // map(2) 169 + 0x62, 'b', 'b', 0x01, // "bb": 1 170 + 0x61, 'a', 0x02, // "a": 2 (shorter, should come first) 171 + })); 172 + } 173 + 174 + test "accept correctly sorted map keys" { 175 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 176 + defer arena.deinit(); 177 + // {"a": 1, "b": 2, "cc": 3} — correct order: short first, then lex 178 + const result = try cbor.decode(arena.allocator(), &.{ 179 + 0xa3, // map(3) 180 + 0x61, 'a', 0x01, // "a": 1 181 + 0x61, 'b', 0x02, // "b": 2 182 + 0x62, 'c', 'c', 0x03, // "cc": 3 183 + }); 184 + try std.testing.expectEqual(@as(u64, 1), result.value.get("a").?.unsigned); 185 + try std.testing.expectEqual(@as(u64, 2), result.value.get("b").?.unsigned); 186 + try std.testing.expectEqual(@as(u64, 3), result.value.get("cc").?.unsigned); 187 + } 188 + 189 + // === duplicate map key rejection === 190 + 191 + test "reject duplicate map keys" { 192 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 193 + defer arena.deinit(); 194 + // {"a": 1, "a": 2} — duplicate key "a" 195 + try std.testing.expectError(error.DuplicateMapKey, cbor.decode(arena.allocator(), &.{ 196 + 0xa2, // map(2) 197 + 0x61, 'a', 0x01, // "a": 1 198 + 0x61, 'a', 0x02, // "a": 2 (duplicate!) 199 + })); 200 + } 201 + 202 + // === float rejection (all variants) === 203 + 204 + test "reject float16" { 205 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 206 + defer arena.deinit(); 207 + try std.testing.expectError(error.UnsupportedFloat, cbor.decode(arena.allocator(), &.{ 0xf9, 0x00, 0x00 })); 208 + } 209 + 210 + test "reject float32" { 211 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 212 + defer arena.deinit(); 213 + // 0xfa 0x47 0xc3 0x50 0x00 = float32(100000.0) 214 + try std.testing.expectError(error.UnsupportedFloat, cbor.decode(arena.allocator(), &.{ 0xfa, 0x47, 0xc3, 0x50, 0x00 })); 215 + } 216 + 217 + test "reject float64" { 218 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 219 + defer arena.deinit(); 220 + // 0xfb + 8 bytes = float64(1.0) 221 + try std.testing.expectError(error.UnsupportedFloat, cbor.decode(arena.allocator(), &.{ 0xfb, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })); 222 + } 223 + 224 + // === simple values rejection === 225 + 226 + test "reject undefined (0xf7)" { 227 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 228 + defer arena.deinit(); 229 + try std.testing.expectError(error.UnsupportedSimpleValue, cbor.decode(arena.allocator(), &.{0xf7})); 230 + } 231 + 232 + test "reject simple value 0" { 233 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 234 + defer arena.deinit(); 235 + // 0xf8 0x00 = simple(0) — only false/true/null allowed 236 + try std.testing.expectError(error.UnsupportedSimpleValue, cbor.decode(arena.allocator(), &.{ 0xf8, 0x00 })); 237 + } 238 + 239 + test "reject simple value 32" { 240 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 241 + defer arena.deinit(); 242 + try std.testing.expectError(error.UnsupportedSimpleValue, cbor.decode(arena.allocator(), &.{ 0xf8, 0x20 })); 243 + } 244 + 245 + test "reject simple value 255" { 246 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 247 + defer arena.deinit(); 248 + try std.testing.expectError(error.UnsupportedSimpleValue, cbor.decode(arena.allocator(), &.{ 0xf8, 0xff })); 249 + } 250 + 251 + // === indefinite-length rejection (all types) === 252 + 253 + test "reject indefinite-length byte string (0x5f)" { 254 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 255 + defer arena.deinit(); 256 + try std.testing.expectError(error.IndefiniteLength, cbor.decode(arena.allocator(), &.{0x5f})); 257 + } 258 + 259 + test "reject indefinite-length text string (0x7f)" { 260 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 261 + defer arena.deinit(); 262 + try std.testing.expectError(error.IndefiniteLength, cbor.decode(arena.allocator(), &.{0x7f})); 263 + } 264 + 265 + test "reject indefinite-length array (0x9f)" { 266 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 267 + defer arena.deinit(); 268 + try std.testing.expectError(error.IndefiniteLength, cbor.decode(arena.allocator(), &.{0x9f})); 269 + } 270 + 271 + test "reject indefinite-length map (0xbf)" { 272 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 273 + defer arena.deinit(); 274 + try std.testing.expectError(error.IndefiniteLength, cbor.decode(arena.allocator(), &.{0xbf})); 275 + } 276 + 277 + test "reject break stop code (0xff)" { 278 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 279 + defer arena.deinit(); 280 + try std.testing.expectError(error.IndefiniteLength, cbor.decode(arena.allocator(), &.{0xff})); 281 + } 282 + 283 + // === reserved additional info (28, 29, 30) for all major types === 284 + 285 + test "reject reserved additional info for unsigned" { 286 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 287 + defer arena.deinit(); 288 + // additional 28 = 0x1c, 29 = 0x1d, 30 = 0x1e 289 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x1c})); 290 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x1d})); 291 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x1e})); 292 + } 293 + 294 + test "reject reserved additional info for negative" { 295 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 296 + defer arena.deinit(); 297 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x3c})); 298 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x3d})); 299 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x3e})); 300 + } 301 + 302 + test "reject reserved additional info for byte string" { 303 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 304 + defer arena.deinit(); 305 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x5c})); 306 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x5d})); 307 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x5e})); 308 + } 309 + 310 + test "reject reserved additional info for text string" { 311 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 312 + defer arena.deinit(); 313 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x7c})); 314 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x7d})); 315 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x7e})); 316 + } 317 + 318 + test "reject reserved additional info for array" { 319 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 320 + defer arena.deinit(); 321 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x9c})); 322 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x9d})); 323 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0x9e})); 324 + } 325 + 326 + test "reject reserved additional info for map" { 327 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 328 + defer arena.deinit(); 329 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0xbc})); 330 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0xbd})); 331 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0xbe})); 332 + } 333 + 334 + test "reject reserved additional info for tag" { 335 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 336 + defer arena.deinit(); 337 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0xdc})); 338 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0xdd})); 339 + try std.testing.expectError(error.ReservedAdditionalInfo, cbor.decode(arena.allocator(), &.{0xde})); 340 + } 341 + 342 + // === integer boundary encode/decode === 343 + 344 + test "integer boundary: 255 (max 1-byte)" { 345 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 346 + defer arena.deinit(); 347 + const alloc = arena.allocator(); 348 + 349 + // 0x18 0xff = unsigned(255) 350 + try std.testing.expectEqual(@as(u64, 255), (try cbor.decode(alloc, &.{ 0x18, 0xff })).value.unsigned); 351 + // round-trip 352 + const encoded = try cbor.encodeAlloc(alloc, .{ .unsigned = 255 }); 353 + try std.testing.expectEqualSlices(u8, &.{ 0x18, 0xff }, encoded); 354 + } 355 + 356 + test "integer boundary: 256 (min 2-byte)" { 357 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 358 + defer arena.deinit(); 359 + const alloc = arena.allocator(); 360 + 361 + // 0x19 0x01 0x00 = unsigned(256) 362 + try std.testing.expectEqual(@as(u64, 256), (try cbor.decode(alloc, &.{ 0x19, 0x01, 0x00 })).value.unsigned); 363 + const encoded = try cbor.encodeAlloc(alloc, .{ .unsigned = 256 }); 364 + try std.testing.expectEqualSlices(u8, &.{ 0x19, 0x01, 0x00 }, encoded); 365 + } 366 + 367 + test "integer boundary: 65535 (max 2-byte)" { 368 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 369 + defer arena.deinit(); 370 + const alloc = arena.allocator(); 371 + 372 + try std.testing.expectEqual(@as(u64, 65535), (try cbor.decode(alloc, &.{ 0x19, 0xff, 0xff })).value.unsigned); 373 + const encoded = try cbor.encodeAlloc(alloc, .{ .unsigned = 65535 }); 374 + try std.testing.expectEqualSlices(u8, &.{ 0x19, 0xff, 0xff }, encoded); 375 + } 376 + 377 + test "integer boundary: 65536 (min 4-byte)" { 378 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 379 + defer arena.deinit(); 380 + const alloc = arena.allocator(); 381 + 382 + try std.testing.expectEqual(@as(u64, 65536), (try cbor.decode(alloc, &.{ 0x1a, 0x00, 0x01, 0x00, 0x00 })).value.unsigned); 383 + const encoded = try cbor.encodeAlloc(alloc, .{ .unsigned = 65536 }); 384 + try std.testing.expectEqualSlices(u8, &.{ 0x1a, 0x00, 0x01, 0x00, 0x00 }, encoded); 385 + } 386 + 387 + test "integer boundary: 0xffffffff (max 4-byte)" { 388 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 389 + defer arena.deinit(); 390 + const alloc = arena.allocator(); 391 + 392 + try std.testing.expectEqual(@as(u64, 0xffffffff), (try cbor.decode(alloc, &.{ 0x1a, 0xff, 0xff, 0xff, 0xff })).value.unsigned); 393 + const encoded = try cbor.encodeAlloc(alloc, .{ .unsigned = 0xffffffff }); 394 + try std.testing.expectEqualSlices(u8, &.{ 0x1a, 0xff, 0xff, 0xff, 0xff }, encoded); 395 + } 396 + 397 + test "integer boundary: 0x100000000 (min 8-byte)" { 398 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 399 + defer arena.deinit(); 400 + const alloc = arena.allocator(); 401 + 402 + try std.testing.expectEqual(@as(u64, 0x100000000), (try cbor.decode(alloc, &.{ 0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 })).value.unsigned); 403 + const encoded = try cbor.encodeAlloc(alloc, .{ .unsigned = 0x100000000 }); 404 + try std.testing.expectEqualSlices(u8, &.{ 0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 }, encoded); 405 + } 406 + 407 + test "integer boundary: max u64" { 408 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 409 + defer arena.deinit(); 410 + const alloc = arena.allocator(); 411 + 412 + const max_u64: u64 = std.math.maxInt(u64); 413 + try std.testing.expectEqual(max_u64, (try cbor.decode(alloc, &.{ 0x1b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff })).value.unsigned); 414 + } 415 + 416 + // === negative integer boundary tests === 417 + 418 + test "negative boundary: -24 (max inline)" { 419 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 420 + defer arena.deinit(); 421 + const alloc = arena.allocator(); 422 + 423 + // -24 = major 1, additional 23 → 0x37 424 + try std.testing.expectEqual(@as(i64, -24), (try cbor.decode(alloc, &.{0x37})).value.negative); 425 + const encoded = try cbor.encodeAlloc(alloc, .{ .negative = -24 }); 426 + try std.testing.expectEqualSlices(u8, &.{0x37}, encoded); 427 + } 428 + 429 + test "negative boundary: -25 (min 1-byte additional)" { 430 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 431 + defer arena.deinit(); 432 + const alloc = arena.allocator(); 433 + 434 + // -25 = major 1, additional 24 → 0x38 0x18 435 + try std.testing.expectEqual(@as(i64, -25), (try cbor.decode(alloc, &.{ 0x38, 0x18 })).value.negative); 436 + const encoded = try cbor.encodeAlloc(alloc, .{ .negative = -25 }); 437 + try std.testing.expectEqualSlices(u8, &.{ 0x38, 0x18 }, encoded); 438 + } 439 + 440 + test "negative boundary: -256 (max 1-byte additional)" { 441 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 442 + defer arena.deinit(); 443 + const alloc = arena.allocator(); 444 + 445 + // -256 = -1 - 255 → 0x38 0xff 446 + try std.testing.expectEqual(@as(i64, -256), (try cbor.decode(alloc, &.{ 0x38, 0xff })).value.negative); 447 + const encoded = try cbor.encodeAlloc(alloc, .{ .negative = -256 }); 448 + try std.testing.expectEqualSlices(u8, &.{ 0x38, 0xff }, encoded); 449 + } 450 + 451 + test "negative boundary: -257 (min 2-byte additional)" { 452 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 453 + defer arena.deinit(); 454 + const alloc = arena.allocator(); 455 + 456 + // -257 = -1 - 256 → 0x39 0x01 0x00 457 + try std.testing.expectEqual(@as(i64, -257), (try cbor.decode(alloc, &.{ 0x39, 0x01, 0x00 })).value.negative); 458 + const encoded = try cbor.encodeAlloc(alloc, .{ .negative = -257 }); 459 + try std.testing.expectEqualSlices(u8, &.{ 0x39, 0x01, 0x00 }, encoded); 460 + } 461 + 462 + test "negative boundary: -65537 (min 4-byte additional)" { 463 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 464 + defer arena.deinit(); 465 + const alloc = arena.allocator(); 466 + 467 + // -65537 = -1 - 65536 → 0x3a 0x00 0x01 0x00 0x00 468 + try std.testing.expectEqual(@as(i64, -65537), (try cbor.decode(alloc, &.{ 0x3a, 0x00, 0x01, 0x00, 0x00 })).value.negative); 469 + const encoded = try cbor.encodeAlloc(alloc, .{ .negative = -65537 }); 470 + try std.testing.expectEqualSlices(u8, &.{ 0x3a, 0x00, 0x01, 0x00, 0x00 }, encoded); 471 + } 472 + 473 + test "negative boundary: min i64 (-2^63)" { 474 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 475 + defer arena.deinit(); 476 + const alloc = arena.allocator(); 477 + 478 + const min_i64: i64 = std.math.minInt(i64); 479 + // -2^63 = -1 - (2^63 - 1) → 0x3b 0x7f 0xff 0xff 0xff 0xff 0xff 0xff 0xff 480 + try std.testing.expectEqual(min_i64, (try cbor.decode(alloc, &.{ 0x3b, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff })).value.negative); 481 + } 482 + 483 + // === negative integer overflow === 484 + 485 + test "reject negative integer overflow: -(2^63 + 1)" { 486 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 487 + defer arena.deinit(); 488 + // 0x3b 0x80 0x00 ... 0x00 = -1 - 2^63 = -(2^63 + 1), overflows i64 489 + try std.testing.expectError(error.Overflow, cbor.decode(arena.allocator(), &.{ 0x3b, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })); 490 + } 491 + 492 + test "reject negative integer overflow: -(2^64)" { 493 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 494 + defer arena.deinit(); 495 + // 0x3b 0xff ... 0xff = -1 - (2^64 - 1) = -2^64, overflows i64 496 + try std.testing.expectError(error.Overflow, cbor.decode(arena.allocator(), &.{ 0x3b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff })); 497 + } 498 + 499 + // === truncated data handling === 500 + 501 + test "reject empty input" { 502 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 503 + defer arena.deinit(); 504 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{})); 505 + } 506 + 507 + test "reject truncated 1-byte unsigned header" { 508 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 509 + defer arena.deinit(); 510 + // 0x18 needs 1 more byte 511 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{0x18})); 512 + } 513 + 514 + test "reject truncated 2-byte unsigned header" { 515 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 516 + defer arena.deinit(); 517 + // 0x19 needs 2 more bytes 518 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{0x19})); 519 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 0x19, 0x01 })); 520 + } 521 + 522 + test "reject truncated 4-byte unsigned header" { 523 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 524 + defer arena.deinit(); 525 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{0x1a})); 526 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 0x1a, 0x00, 0x00 })); 527 + } 528 + 529 + test "reject truncated 8-byte unsigned header" { 530 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 531 + defer arena.deinit(); 532 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{0x1b})); 533 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 0x1b, 0x00, 0x00, 0x00, 0x00 })); 534 + } 535 + 536 + test "reject truncated text string payload" { 537 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 538 + defer arena.deinit(); 539 + // 0x65 = text(5) but only 3 bytes follow 540 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 0x65, 'h', 'e', 'l' })); 541 + } 542 + 543 + test "reject truncated byte string payload" { 544 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 545 + defer arena.deinit(); 546 + // 0x44 = bytes(4) but only 2 bytes follow 547 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 0x44, 0x01, 0x02 })); 548 + } 549 + 550 + test "reject truncated array elements" { 551 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 552 + defer arena.deinit(); 553 + // 0x83 = array(3) but only 2 elements 554 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 0x83, 0x01, 0x02 })); 555 + } 556 + 557 + test "reject truncated map entries" { 558 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 559 + defer arena.deinit(); 560 + // 0xa2 = map(2) but only 1 entry 561 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 562 + 0xa2, 563 + 0x61, 564 + 'a', 565 + 0x01, 566 + })); 567 + } 568 + 569 + // === string/bytes at encoding boundaries === 570 + 571 + test "text string at encoding boundaries" { 572 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 573 + defer arena.deinit(); 574 + const alloc = arena.allocator(); 575 + 576 + // 23-byte string (max inline length) 577 + const s23 = "12345678901234567890123"; 578 + const encoded23 = try cbor.encodeAlloc(alloc, .{ .text = s23 }); 579 + try std.testing.expectEqual(@as(u8, 0x77), encoded23[0]); // 0x60 + 23 580 + const decoded23 = try cbor.decodeAll(alloc, encoded23); 581 + try std.testing.expectEqualStrings(s23, decoded23.text); 582 + 583 + // 24-byte string (first to use 1-byte length) 584 + const s24 = "123456789012345678901234"; 585 + const encoded24 = try cbor.encodeAlloc(alloc, .{ .text = s24 }); 586 + try std.testing.expectEqual(@as(u8, 0x78), encoded24[0]); // text + 1-byte length 587 + try std.testing.expectEqual(@as(u8, 24), encoded24[1]); 588 + const decoded24 = try cbor.decodeAll(alloc, encoded24); 589 + try std.testing.expectEqualStrings(s24, decoded24.text); 590 + } 591 + 592 + test "byte string at encoding boundaries" { 593 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 594 + defer arena.deinit(); 595 + const alloc = arena.allocator(); 596 + 597 + // 23-byte bytes (max inline length) 598 + const b23 = &[_]u8{0xaa} ** 23; 599 + const encoded23 = try cbor.encodeAlloc(alloc, .{ .bytes = b23 }); 600 + try std.testing.expectEqual(@as(u8, 0x57), encoded23[0]); // 0x40 + 23 601 + const decoded23 = try cbor.decodeAll(alloc, encoded23); 602 + try std.testing.expectEqualSlices(u8, b23, decoded23.bytes); 603 + 604 + // 24-byte bytes (first to use 1-byte length) 605 + const b24 = &[_]u8{0xbb} ** 24; 606 + const encoded24 = try cbor.encodeAlloc(alloc, .{ .bytes = b24 }); 607 + try std.testing.expectEqual(@as(u8, 0x58), encoded24[0]); // bytes + 1-byte length 608 + try std.testing.expectEqual(@as(u8, 24), encoded24[1]); 609 + const decoded24 = try cbor.decodeAll(alloc, encoded24); 610 + try std.testing.expectEqualSlices(u8, b24, decoded24.bytes); 611 + } 612 + 613 + // === CID edge cases === 614 + 615 + test "reject tag 42 wrapping non-bytes" { 616 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 617 + defer arena.deinit(); 618 + // tag(42) + text string "hello" instead of byte string 619 + try std.testing.expectError(error.InvalidCid, cbor.decode(arena.allocator(), &.{ 620 + 0xd8, 0x2a, // tag(42) 621 + 0x65, 'h', 'e', 'l', 'l', 'o', // text "hello" (should be bytes) 622 + })); 623 + } 624 + 625 + test "reject tag 42 with empty bytes" { 626 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 627 + defer arena.deinit(); 628 + // tag(42) + empty byte string (missing 0x00 prefix) 629 + try std.testing.expectError(error.InvalidCid, cbor.decode(arena.allocator(), &.{ 630 + 0xd8, 0x2a, // tag(42) 631 + 0x40, // bytes(0) — empty, no 0x00 prefix 632 + })); 633 + } 634 + 635 + test "reject tag 42 with wrong prefix" { 636 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 637 + defer arena.deinit(); 638 + // tag(42) + byte string with 0x01 prefix instead of 0x00 639 + try std.testing.expectError(error.InvalidCid, cbor.decode(arena.allocator(), &.{ 640 + 0xd8, 0x2a, // tag(42) 641 + 0x42, 0x01, 0xaa, // bytes [0x01, 0xaa] — wrong prefix 642 + })); 643 + } 644 + 645 + // === complex nested round-trips === 646 + 647 + test "round-trip: mixed array with all types" { 648 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 649 + defer arena.deinit(); 650 + const alloc = arena.allocator(); 651 + 652 + // [42, -7, "hello", true, false, null, [1, 2], {"k": 0}] 653 + const original: Value = .{ .array = &.{ 654 + .{ .unsigned = 42 }, 655 + .{ .negative = -7 }, 656 + .{ .text = "hello" }, 657 + .{ .boolean = true }, 658 + .{ .boolean = false }, 659 + .null, 660 + .{ .array = &.{ .{ .unsigned = 1 }, .{ .unsigned = 2 } } }, 661 + .{ .map = &.{.{ .key = "k", .value = .{ .unsigned = 0 } }} }, 662 + } }; 663 + 664 + const encoded = try cbor.encodeAlloc(alloc, original); 665 + const decoded = try cbor.decodeAll(alloc, encoded); 666 + 667 + const arr = decoded.array; 668 + try std.testing.expectEqual(@as(usize, 8), arr.len); 669 + try std.testing.expectEqual(@as(u64, 42), arr[0].unsigned); 670 + try std.testing.expectEqual(@as(i64, -7), arr[1].negative); 671 + try std.testing.expectEqualStrings("hello", arr[2].text); 672 + try std.testing.expectEqual(true, arr[3].boolean); 673 + try std.testing.expectEqual(false, arr[4].boolean); 674 + try std.testing.expectEqual(Value.null, arr[5]); 675 + try std.testing.expectEqual(@as(usize, 2), arr[6].array.len); 676 + try std.testing.expectEqual(@as(u64, 0), arr[7].get("k").?.unsigned); 677 + } 678 + 679 + test "round-trip: deeply nested maps" { 680 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 681 + defer arena.deinit(); 682 + const alloc = arena.allocator(); 683 + 684 + // {"a": {"b": {"c": {"d": 42}}}} 685 + const original: Value = .{ .map = &.{ 686 + .{ .key = "a", .value = .{ .map = &.{ 687 + .{ .key = "b", .value = .{ .map = &.{ 688 + .{ .key = "c", .value = .{ .map = &.{ 689 + .{ .key = "d", .value = .{ .unsigned = 42 } }, 690 + } } }, 691 + } } }, 692 + } } }, 693 + } }; 694 + 695 + const encoded = try cbor.encodeAlloc(alloc, original); 696 + const decoded = try cbor.decodeAll(alloc, encoded); 697 + 698 + const d = decoded.get("a").?.get("b").?.get("c").?.get("d").?.unsigned; 699 + try std.testing.expectEqual(@as(u64, 42), d); 700 + } 701 + 702 + test "round-trip: unicode text strings" { 703 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 704 + defer arena.deinit(); 705 + const alloc = arena.allocator(); 706 + 707 + const cases = [_][]const u8{ 708 + "", 709 + "a", 710 + "IETF", 711 + "\"\\\u{00fc}\u{6c34}", 712 + "\xc3\xbc", // ü 713 + "\xe6\xb0\xb4", // 水 714 + "\xf0\x9f\x98\x80", // 😀 715 + "\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", // family ZWJ emoji 716 + }; 717 + 718 + for (cases) |text| { 719 + const encoded = try cbor.encodeAlloc(alloc, .{ .text = text }); 720 + const decoded = try cbor.decodeAll(alloc, encoded); 721 + try std.testing.expectEqualStrings(text, decoded.text); 722 + } 723 + } 724 + 725 + test "round-trip: empty containers" { 726 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 727 + defer arena.deinit(); 728 + const alloc = arena.allocator(); 729 + 730 + // empty array 731 + const empty_arr = try cbor.encodeAlloc(alloc, .{ .array = &.{} }); 732 + try std.testing.expectEqualSlices(u8, &.{0x80}, empty_arr); 733 + const decoded_arr = try cbor.decodeAll(alloc, empty_arr); 734 + try std.testing.expectEqual(@as(usize, 0), decoded_arr.array.len); 735 + 736 + // empty map 737 + const empty_map = try cbor.encodeAlloc(alloc, .{ .map = &.{} }); 738 + try std.testing.expectEqualSlices(u8, &.{0xa0}, empty_map); 739 + const decoded_map = try cbor.decodeAll(alloc, empty_map); 740 + try std.testing.expectEqual(@as(usize, 0), decoded_map.map.len); 741 + } 742 + 743 + test "round-trip: map with empty key" { 744 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 745 + defer arena.deinit(); 746 + const alloc = arena.allocator(); 747 + 748 + const original: Value = .{ .map = &.{ 749 + .{ .key = "", .value = .{ .unsigned = 1 } }, 750 + .{ .key = "a", .value = .{ .unsigned = 2 } }, 751 + } }; 752 + 753 + const encoded = try cbor.encodeAlloc(alloc, original); 754 + const decoded = try cbor.decodeAll(alloc, encoded); 755 + 756 + // empty key should sort first (shorter) 757 + try std.testing.expectEqualStrings("", decoded.map[0].key); 758 + try std.testing.expectEqualStrings("a", decoded.map[1].key); 759 + try std.testing.expectEqual(@as(u64, 1), decoded.get("").?.unsigned); 760 + try std.testing.expectEqual(@as(u64, 2), decoded.get("a").?.unsigned); 761 + } 762 + 763 + // === byte-identical re-encoding === 764 + 765 + test "canonical re-encoding: encode then decode then re-encode is identical" { 766 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 767 + defer arena.deinit(); 768 + const alloc = arena.allocator(); 769 + 770 + // build a non-trivially-ordered map 771 + const original: Value = .{ .map = &.{ 772 + .{ .key = "z", .value = .{ .unsigned = 26 } }, 773 + .{ .key = "a", .value = .{ .text = "first" } }, 774 + .{ .key = "mm", .value = .{ .array = &.{ 775 + .{ .boolean = true }, 776 + .null, 777 + .{ .negative = -100 }, 778 + } } }, 779 + } }; 780 + 781 + const first_encode = try cbor.encodeAlloc(alloc, original); 782 + const decoded = try cbor.decodeAll(alloc, first_encode); 783 + const second_encode = try cbor.encodeAlloc(alloc, decoded); 784 + 785 + try std.testing.expectEqualSlices(u8, first_encode, second_encode); 786 + } 787 + 788 + test "deterministic encoding: 10 iterations produce identical bytes" { 789 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 790 + defer arena.deinit(); 791 + const alloc = arena.allocator(); 792 + 793 + const value: Value = .{ .map = &.{ 794 + .{ .key = "type", .value = .{ .text = "app.bsky.feed.post" } }, 795 + .{ .key = "text", .value = .{ .text = "Hello, world!" } }, 796 + .{ .key = "createdAt", .value = .{ .text = "2024-01-01T00:00:00Z" } }, 797 + .{ .key = "langs", .value = .{ .array = &.{.{ .text = "en" }} } }, 798 + } }; 799 + 800 + const first = try cbor.encodeAlloc(alloc, value); 801 + for (0..10) |_| { 802 + const again = try cbor.encodeAlloc(alloc, value); 803 + try std.testing.expectEqualSlices(u8, first, again); 804 + } 805 + } 806 + 807 + // === single-byte exhaustive scan === 808 + // 809 + // every possible single-byte CBOR input should either decode or return 810 + // a well-defined error — never panic or trigger undefined behavior. 811 + 812 + test "single-byte exhaustive: no panics on any byte value" { 813 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 814 + defer arena.deinit(); 815 + const alloc = arena.allocator(); 816 + 817 + var byte: u16 = 0; 818 + while (byte <= 255) : (byte += 1) { 819 + const data = [_]u8{@intCast(byte)}; 820 + _ = cbor.decode(alloc, &data) catch continue; 821 + } 822 + } 823 + 824 + // === non-string map key rejection === 825 + 826 + test "reject integer map key" { 827 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 828 + defer arena.deinit(); 829 + // map(1) with integer key: {1: 2} 830 + try std.testing.expectError(error.InvalidMapKey, cbor.decode(arena.allocator(), &.{ 831 + 0xa1, // map(1) 832 + 0x01, // key: integer 1 (not text!) 833 + 0x02, // value: 2 834 + })); 835 + } 836 + 837 + test "reject bytes map key" { 838 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 839 + defer arena.deinit(); 840 + // map(1) with byte string key 841 + try std.testing.expectError(error.InvalidMapKey, cbor.decode(arena.allocator(), &.{ 842 + 0xa1, // map(1) 843 + 0x41, 0x01, // key: bytes [0x01] (not text!) 844 + 0x02, // value: 2 845 + })); 846 + } 847 + 848 + test "reject array map key" { 849 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 850 + defer arena.deinit(); 851 + try std.testing.expectError(error.InvalidMapKey, cbor.decode(arena.allocator(), &.{ 852 + 0xa1, // map(1) 853 + 0x80, // key: empty array (not text!) 854 + 0x02, // value: 2 855 + })); 856 + } 857 + 858 + test "reject boolean map key" { 859 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 860 + defer arena.deinit(); 861 + try std.testing.expectError(error.InvalidMapKey, cbor.decode(arena.allocator(), &.{ 862 + 0xa1, // map(1) 863 + 0xf5, // key: true (not text!) 864 + 0x02, // value: 2 865 + })); 866 + } 867 + 868 + // === map key ordering: comprehensive DAG-CBOR rules === 869 + 870 + test "map key ordering: length takes priority over lexicographic" { 871 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 872 + defer arena.deinit(); 873 + const alloc = arena.allocator(); 874 + 875 + // "z" (1 byte) must come before "aa" (2 bytes), even though "aa" < "z" lexicographically 876 + const original: Value = .{ .map = &.{ 877 + .{ .key = "aa", .value = .{ .unsigned = 2 } }, 878 + .{ .key = "z", .value = .{ .unsigned = 1 } }, 879 + } }; 880 + 881 + const encoded = try cbor.encodeAlloc(alloc, original); 882 + const decoded = try cbor.decodeAll(alloc, encoded); 883 + 884 + try std.testing.expectEqualStrings("z", decoded.map[0].key); 885 + try std.testing.expectEqualStrings("aa", decoded.map[1].key); 886 + } 887 + 888 + test "map key ordering: empty key sorts first" { 889 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 890 + defer arena.deinit(); 891 + const alloc = arena.allocator(); 892 + 893 + const original: Value = .{ .map = &.{ 894 + .{ .key = "a", .value = .{ .unsigned = 2 } }, 895 + .{ .key = "", .value = .{ .unsigned = 1 } }, 896 + .{ .key = "bb", .value = .{ .unsigned = 3 } }, 897 + } }; 898 + 899 + const encoded = try cbor.encodeAlloc(alloc, original); 900 + const decoded = try cbor.decodeAll(alloc, encoded); 901 + 902 + try std.testing.expectEqualStrings("", decoded.map[0].key); 903 + try std.testing.expectEqualStrings("a", decoded.map[1].key); 904 + try std.testing.expectEqualStrings("bb", decoded.map[2].key); 905 + } 906 + 907 + // === UTF-8 validation === 908 + // 909 + // CBOR text strings (major type 3) must contain valid UTF-8. 910 + // DAG-CBOR inherits this requirement. 911 + 912 + test "reject invalid UTF-8 in text string: 0xff byte" { 913 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 914 + defer arena.deinit(); 915 + // text(1) containing 0xff — not valid UTF-8 916 + try std.testing.expectError(error.InvalidUtf8, cbor.decode(arena.allocator(), &.{ 0x61, 0xff })); 917 + } 918 + 919 + test "reject invalid UTF-8 in text string: truncated multi-byte sequence" { 920 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 921 + defer arena.deinit(); 922 + // text(1) containing 0xc3 — start of 2-byte sequence but missing continuation 923 + try std.testing.expectError(error.InvalidUtf8, cbor.decode(arena.allocator(), &.{ 0x61, 0xc3 })); 924 + } 925 + 926 + test "reject invalid UTF-8 in text string: lone continuation byte" { 927 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 928 + defer arena.deinit(); 929 + // text(1) containing 0x80 — continuation byte without start 930 + try std.testing.expectError(error.InvalidUtf8, cbor.decode(arena.allocator(), &.{ 0x61, 0x80 })); 931 + } 932 + 933 + test "reject invalid UTF-8 in text string: surrogate half (U+D800)" { 934 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 935 + defer arena.deinit(); 936 + // text(3) containing ED A0 80 — UTF-8 encoding of U+D800 (surrogate) 937 + try std.testing.expectError(error.InvalidUtf8, cbor.decode(arena.allocator(), &.{ 0x63, 0xed, 0xa0, 0x80 })); 938 + } 939 + 940 + test "reject invalid UTF-8 in text string: overlong encoding" { 941 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 942 + defer arena.deinit(); 943 + // text(2) containing C0 80 — overlong encoding of U+0000 944 + try std.testing.expectError(error.InvalidUtf8, cbor.decode(arena.allocator(), &.{ 0x62, 0xc0, 0x80 })); 945 + } 946 + 947 + test "reject invalid UTF-8 in map key" { 948 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 949 + defer arena.deinit(); 950 + // map(1) with key containing invalid UTF-8 951 + try std.testing.expectError(error.InvalidUtf8, cbor.decode(arena.allocator(), &.{ 952 + 0xa1, // map(1) 953 + 0x61, 0xff, // key: text(1) with 0xff — invalid UTF-8 954 + 0x01, // value: 1 955 + })); 956 + } 957 + 958 + test "accept valid UTF-8 text: café" { 959 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 960 + defer arena.deinit(); 961 + // "café" = 63 61 66 c3 a9 — valid UTF-8 962 + const result = try cbor.decode(arena.allocator(), &.{ 0x65, 'c', 'a', 'f', 0xc3, 0xa9 }); 963 + try std.testing.expectEqualStrings("caf\xc3\xa9", result.value.text); 964 + } 965 + 966 + test "accept valid UTF-8 text: CJK and emoji" { 967 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 968 + defer arena.deinit(); 969 + const alloc = arena.allocator(); 970 + 971 + // encode and decode a string with multi-byte characters 972 + const text = "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e\xf0\x9f\x98\x80"; // 日本語😀 973 + const encoded = try cbor.encodeAlloc(alloc, .{ .text = text }); 974 + const decoded = try cbor.decodeAll(alloc, encoded); 975 + try std.testing.expectEqualStrings(text, decoded.text); 976 + } 977 + 978 + // === nesting depth limit === 979 + 980 + test "accept nesting at max_depth - 1" { 981 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 982 + defer arena.deinit(); 983 + 984 + // build array(1) nested max_depth - 1 times, with 0 at the bottom 985 + var buf: [cbor.max_depth + 1]u8 = undefined; 986 + for (0..cbor.max_depth - 1) |i| { 987 + buf[i] = 0x81; // array(1) 988 + } 989 + buf[cbor.max_depth - 1] = 0x00; // integer 0 at the bottom 990 + 991 + const result = try cbor.decodeAll(arena.allocator(), buf[0..cbor.max_depth]); 992 + // verify outermost is an array 993 + try std.testing.expectEqual(@as(usize, 1), result.array.len); 994 + } 995 + 996 + test "reject nesting beyond max_depth" { 997 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 998 + defer arena.deinit(); 999 + 1000 + // build array(1) nested max_depth + 1 times 1001 + var buf: [cbor.max_depth + 2]u8 = undefined; 1002 + for (0..cbor.max_depth + 1) |i| { 1003 + buf[i] = 0x81; // array(1) 1004 + } 1005 + buf[cbor.max_depth + 1] = 0x00; 1006 + 1007 + try std.testing.expectError(error.MaxDepthExceeded, cbor.decodeAll(arena.allocator(), buf[0 .. cbor.max_depth + 2])); 1008 + } 1009 + 1010 + // === huge allocation rejection === 1011 + // 1012 + // the decoder should reject claims for impossibly large collections 1013 + // without attempting to allocate. 1014 + 1015 + test "reject huge array allocation claim" { 1016 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1017 + defer arena.deinit(); 1018 + // 0x9b + 8 bytes claiming 2^32 elements, but only a few bytes of data follow 1019 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 1020 + 0x9b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // array(2^32) 1021 + 0x00, // just one byte of data 1022 + })); 1023 + } 1024 + 1025 + test "reject huge map allocation claim" { 1026 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1027 + defer arena.deinit(); 1028 + // map claiming 2^32 entries with minimal data 1029 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 1030 + 0xbb, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // map(2^32) 1031 + 0x61, 'a', 0x01, // one entry 1032 + })); 1033 + } 1034 + 1035 + test "reject huge byte string claim" { 1036 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1037 + defer arena.deinit(); 1038 + // bytes claiming 2^32 length with minimal data 1039 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 1040 + 0x5b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // bytes(2^32) 1041 + 0x00, // just one byte 1042 + })); 1043 + } 1044 + 1045 + test "reject huge text string claim" { 1046 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1047 + defer arena.deinit(); 1048 + // text claiming 2^32 length with minimal data 1049 + try std.testing.expectError(error.UnexpectedEof, cbor.decode(arena.allocator(), &.{ 1050 + 0x7b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // text(2^32) 1051 + 0x00, // just one byte 1052 + })); 1053 + }
+1
src/root.zig
··· 73 73 if (@import("builtin").is_test) { 74 74 _ = @import("internal/testing/interop_tests.zig"); 75 75 _ = @import("internal/repo/repo_verifier.zig"); 76 + _ = @import("internal/repo/cbor_test.zig"); 76 77 } 77 78 }