From 8222e624dc649641fdbebbcc275ff15611d57e26 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 15 Jul 2026 15:58:48 +0200 Subject: [PATCH] cdp: emit non-UTF-8 header values as Latin-1 strings, not byte arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std.json serializes []u8 that isn't valid UTF-8 as a JSON array of numbers, so headers like "expires: mié, 15 jul 2026 ..." (Latin-1 0xE9) appeared as byte arrays in Network.responseReceived. Stream such values through the JSON writer with Latin-1 -> UTF-8 transcoding, matching Chrome's behavior for DevTools. --- src/cdp/domains/network.zig | 73 ++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index c5e07e2a0..317e014bd 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -433,7 +433,7 @@ pub const RequestWriter = struct { var it = request.headers.iterator(); while (it.next()) |hdr| { try jws.objectField(hdr.name); - try jws.write(hdr.value); + try writeHeaderValue(jws, hdr.value); } if (try request.getCookieString(transfer.arena)) |cookies| { try jws.objectField("Cookie"); @@ -534,12 +534,45 @@ const ResponseWriter = struct { } try jws.objectField("headers"); - try jws.write(std.json.ArrayHashMap([]const u8){ .map = map }); + try jws.beginObject(); + var map_it = map.iterator(); + while (map_it.next()) |entry| { + try jws.objectField(entry.key_ptr.*); + try writeHeaderValue(jws, entry.value_ptr.*); + } + try jws.endObject(); } try jws.endObject(); } }; +// HTTP header values are octets; per historical practice non-UTF-8 bytes are +// interpreted as Latin-1 (ISO-8859-1), which is what Chrome does for DevTools. +// Transcode so we emit a JSON string — std.json would otherwise serialize +// invalid UTF-8 as a JSON array of numbers. +fn writeHeaderValue(jws: anytype, value: []const u8) !void { + if (std.unicode.utf8ValidateSlice(value)) { + return jws.write(value); + } + // Latin-1 -> UTF-8: each byte is a codepoint U+0000..U+00FF (max 2 bytes) + try jws.beginWriteRaw(); + try jws.writer.writeByte('"'); + var start: usize = 0; + for (value, 0..) |b, i| { + if (b < 0x80) { + continue; + } + try std.json.Stringify.encodeJsonStringChars(value[start..i], jws.options, jws.writer); + var buf: [2]u8 = undefined; + const n = std.unicode.utf8Encode(b, &buf) catch unreachable; + try jws.writer.writeAll(buf[0..n]); + start = i + 1; + } + try std.json.Stringify.encodeJsonStringChars(value[start..], jws.options, jws.writer); + try jws.writer.writeByte('"'); + jws.endWriteRaw(); +} + fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey { const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams; @@ -676,6 +709,42 @@ test "cdp.network setExtraHTTPHeaders rejects a header that smuggles CRLF" { try testing.expectEqual("x-keep: ok", std.mem.span(bc.extra_headers.items[0])); } +test "cdp.network writeHeaderValue" { + const expectHeaderJson = struct { + fn expect(expected: []const u8, value: []const u8) !void { + var buf: [256]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + var jws: std.json.Stringify = .{ .writer = &writer }; + try writeHeaderValue(&jws, value); + try std.testing.expectEqualStrings(expected, writer.buffered()); + } + }.expect; + + // valid UTF-8 is written as-is + try expectHeaderJson( + "\"mié, 15 jul 2026 13:19:10 GMT\"", + "mié, 15 jul 2026 13:19:10 GMT", + ); + + // Latin-1 bytes are transcoded to UTF-8 instead of a byte array + try expectHeaderJson( + "\"mié, 15 jul 2026 13:19:10 GMT\"", + "mi\xE9, 15 jul 2026 13:19:10 GMT", + ); + + // JSON escaping still applies around transcoded bytes + try expectHeaderJson( + "\"a\\\"é\\nb\"", + "a\"\xE9\nb", + ); + + // pure ASCII untouched + try expectHeaderJson( + "\"max-age=180, s-maxage=180, public\"", + "max-age=180, s-maxage=180, public", + ); +} + test "cdp.Network: cookies" { const ResCookie = CdpStorage.ResCookie; const CdpCookie = CdpStorage.CdpCookie;