diff --git a/src/internal/repo/cbor.zig b/src/internal/repo/cbor.zig index b010803..44229bf 100644 --- a/src/internal/repo/cbor.zig +++ b/src/internal/repo/cbor.zig @@ -756,6 +756,103 @@ pub fn readCidLink(data: []const u8, pos: usize) DecodeError!SliceResult { return .{ .val = payload[1..], .end = bytes_result.end }; } +// --------------------------------------------------------------------------- +// Streaming helpers — skip / peek without full decode +// --------------------------------------------------------------------------- + +/// Skip one CBOR value at `pos` without decoding it. Returns the position +/// after the skipped value. Iterative (not recursive) using a small stack +/// for nested containers. Zero allocation. +pub fn skipValue(data: []const u8, pos: usize) DecodeError!usize { + const max_stack = 32; + var stack: [max_stack]u64 = undefined; + var depth: usize = 0; + var cur = pos; + + while (true) { + const arg = try readArg(data, cur); + cur = arg.end; + + switch (arg.major) { + 0, 1 => { + // integers: header only, nothing to skip after readArg + }, + 2, 3 => { + // byte string / text string: skip `val` bytes of payload + if (cur + arg.val > data.len) return error.UnexpectedEof; + cur += @intCast(arg.val); + }, + 4 => { + // array: push element count + if (arg.val > 0) { + if (depth >= max_stack) return error.MaxDepthExceeded; + stack[depth] = arg.val; + depth += 1; + continue; // don't decrement — we haven't consumed an element yet + } + }, + 5 => { + // map: push key+value count (2 per entry) + if (arg.val > 0) { + if (depth >= max_stack) return error.MaxDepthExceeded; + stack[depth] = arg.val * 2; + depth += 1; + continue; + } + }, + 6 => { + // tag: the tagged value follows immediately — loop to read it + // don't push anything, don't decrement + continue; + }, + 7 => { + // simple/float: header only + }, + } + + // After consuming a value, unwind the stack + while (depth > 0) { + stack[depth - 1] -= 1; + if (stack[depth - 1] > 0) break; + depth -= 1; + } + + if (depth == 0) return cur; + } +} + +/// Peek at the "$type" field in a DAG-CBOR map without full decode. +/// Returns the type string (zero-copy slice) or null if not found. +pub fn peekType(data: []const u8) DecodeError!?[]const u8 { + return peekTypeAt(data, 0); +} + +/// Peek at the "$type" field starting from a given position. +pub fn peekTypeAt(data: []const u8, pos: usize) DecodeError!?[]const u8 { + const map_header = try readArg(data, pos); + if (map_header.major != 5) return null; + + var cur = map_header.end; + const count = map_header.val; + + for (0..@as(usize, @intCast(count))) |_| { + // Read key — DAG-CBOR keys are always text strings + const key = readText(data, cur) catch return null; + cur = key.end; + + if (std.mem.eql(u8, key.val, "$type")) { + // Read the value as text + const val = readText(data, cur) catch return null; + return val.val; + } + + // Skip the value + cur = try skipValue(data, cur); + } + + return null; +} + // === tests === test "decode unsigned integers" { diff --git a/src/internal/repo/cbor_read_test.zig b/src/internal/repo/cbor_read_test.zig index 36d8ed3..a0f5d2e 100644 --- a/src/internal/repo/cbor_read_test.zig +++ b/src/internal/repo/cbor_read_test.zig @@ -366,3 +366,137 @@ test "readCidLink: reject non-tag" { const data = [_]u8{ 0x43, 0x01, 0x02, 0x03 }; // major 2 (bytes), not a tag try std.testing.expectError(error.WrongType, readCidLink(&data, 0)); } + +// =========================================================================== +// skipValue +// =========================================================================== + +const skipValue = cbor.skipValue; +const peekType = cbor.peekType; +const peekTypeAt = cbor.peekTypeAt; +const encodeAlloc = cbor.encodeAlloc; +const Value = cbor.Value; + +test "skipValue: unsigned integer (1 byte)" { + // 0x05 = major 0, value 5 + const data = [_]u8{0x05}; + const end = try skipValue(&data, 0); + try std.testing.expectEqual(@as(usize, 1), end); +} + +test "skipValue: text string (header + payload)" { + // 0x65 = major 3, length 5 + "hello" + const data = [_]u8{ 0x65, 'h', 'e', 'l', 'l', 'o' }; + const end = try skipValue(&data, 0); + try std.testing.expectEqual(@as(usize, 6), end); +} + +test "skipValue: nested map {\"a\": [1, 2]}" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build {"a": [1, 2]} using the encoder + const val = Value{ .map = &.{ + .{ .key = "a", .value = .{ .array = &.{ + .{ .unsigned = 1 }, + .{ .unsigned = 2 }, + } } }, + } }; + const encoded = try encodeAlloc(alloc, val); + const end = try skipValue(encoded, 0); + try std.testing.expectEqual(encoded.len, end); +} + +test "skipValue: CID link (tag 42 + byte string)" { + // tag(42): 0xd8 0x2a + // bytes(37): 0x58 0x25 (37 = 1 prefix + 36 CID) + // 0x00 prefix + 36-byte CID + const cid_raw = [_]u8{ 0x01, 0x71, 0x12, 0x20 } ++ [_]u8{0xaa} ** 32; + const data = [_]u8{ 0xd8, 0x2a, 0x58, 0x25, 0x00 } ++ cid_raw; + const end = try skipValue(&data, 0); + try std.testing.expectEqual(data.len, end); +} + +test "skipValue: first of two concatenated values" { + // Two values: unsigned 5 (0x05) followed by unsigned 10 (0x0a) + const data = [_]u8{ 0x05, 0x0a }; + const end = try skipValue(&data, 0); + try std.testing.expectEqual(@as(usize, 1), end); + // The second value starts at position 1 + try std.testing.expectEqual(@as(u8, 0x0a), data[end]); +} + +test "skipValue: complex record (encoded map)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Encode a realistic map with multiple types + const val = Value{ .map = &.{ + .{ .key = "$type", .value = .{ .text = "app.bsky.feed.post" } }, + .{ .key = "createdAt", .value = .{ .text = "2024-01-01T00:00:00Z" } }, + .{ .key = "text", .value = .{ .text = "hello world" } }, + } }; + const encoded = try encodeAlloc(alloc, val); + const end = try skipValue(encoded, 0); + try std.testing.expectEqual(encoded.len, end); +} + +// =========================================================================== +// peekType +// =========================================================================== + +test "peekType: find $type when present" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const val = Value{ .map = &.{ + .{ .key = "$type", .value = .{ .text = "app.bsky.feed.post" } }, + .{ .key = "text", .value = .{ .text = "hello" } }, + } }; + const encoded = try encodeAlloc(alloc, val); + const result = try peekType(encoded); + try std.testing.expect(result != null); + try std.testing.expectEqualStrings("app.bsky.feed.post", result.?); +} + +test "peekType: find $type when not first key (DAG-CBOR sort order)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // DAG-CBOR sorts by length then lex. Keys "ab" (2 bytes) sorts before + // "$type" (5 bytes), so "$type" won't be first. + const val = Value{ .map = &.{ + .{ .key = "ab", .value = .{ .unsigned = 42 } }, + .{ .key = "$type", .value = .{ .text = "app.bsky.graph.follow" } }, + .{ .key = "zzzzzz", .value = .{ .boolean = true } }, + } }; + const encoded = try encodeAlloc(alloc, val); + const result = try peekType(encoded); + try std.testing.expect(result != null); + try std.testing.expectEqualStrings("app.bsky.graph.follow", result.?); +} + +test "peekType: return null when no $type field" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const val = Value{ .map = &.{ + .{ .key = "text", .value = .{ .text = "hello" } }, + .{ .key = "count", .value = .{ .unsigned = 5 } }, + } }; + const encoded = try encodeAlloc(alloc, val); + const result = try peekType(encoded); + try std.testing.expect(result == null); +} + +test "peekType: return null for non-map input" { + // An unsigned integer, not a map + const data = [_]u8{0x05}; + const result = try peekType(&data); + try std.testing.expect(result == null); +}