From 2d4cdccdf0074821d0276fc22257126540ac5e4e Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 30 Mar 2026 15:19:30 -0700 Subject: [PATCH] add Vary support --- ...6f2dcc1e6022693e854809c72179a7a486b7.cache | Bin 10197 -> 0 bytes src/browser/HttpClient.zig | 65 +++--- src/network/cache/Cache.zig | 26 +-- src/network/cache/FsCache.zig | 187 ++++++++++++++++-- src/network/http.zig | 43 ++-- 5 files changed, 246 insertions(+), 75 deletions(-) delete mode 100644 lp-net-cache/6ba046812fd9e6b6c7e484ec946c6f2dcc1e6022693e854809c72179a7a486b7.cache diff --git a/lp-net-cache/6ba046812fd9e6b6c7e484ec946c6f2dcc1e6022693e854809c72179a7a486b7.cache b/lp-net-cache/6ba046812fd9e6b6c7e484ec946c6f2dcc1e6022693e854809c72179a7a486b7.cache deleted file mode 100644 index a944dbb8e2443049422f18307899943f92913c61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10197 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 T#F*l}CCy4)_`G{_d2{n$>p;9@ diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index e4c7a8f2..98a2c2eb 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -367,7 +367,14 @@ fn processRequest(self: *Client, req: Request) !void { const arena = try self.network.app.arena_pool.acquire(.{ .debug = "HttpClient.processRequest.cache" }); defer self.network.app.arena_pool.release(arena); - if (cache.get(arena, .{ .url = req.url, .timestamp = std.time.timestamp() })) |cached| { + var iter = req.headers.iterator(); + const req_header_list = try iter.collect(arena); + + if (cache.get(arena, .{ + .url = req.url, + .timestamp = std.time.timestamp(), + .request_headers = req_header_list.items, + })) |cached| { log.debug(.browser, "http.cache.get", .{ .url = req.url, .found = true, @@ -963,23 +970,6 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T } } - const allocator = transfer.arena.allocator(); - var header_list: std.ArrayList(http.Header) = .empty; - - var it = transfer.responseHeaderIterator(); - while (it.next()) |hdr| { - header_list.append( - allocator, - .{ - .name = try allocator.dupe(u8, hdr.name), - .value = try allocator.dupe(u8, hdr.value), - }, - ) catch |err| { - log.warn(.http, "cache header collect failed", .{ .err = err }); - break; - }; - } - // release conn ASAP so that it's available; some done_callbacks // will load more resources. transfer.releaseConn(); @@ -1562,6 +1552,8 @@ pub const Transfer = struct { const rh = &transfer.response_header.?; const allocator = transfer.arena.allocator(); + const vary = if (conn.getResponseHeader("vary", 0)) |h| h.value else null; + const maybe_cm = try Cache.tryCache( allocator, std.time.timestamp(), @@ -1569,7 +1561,7 @@ pub const Transfer = struct { rh.status, rh.contentType(), if (conn.getResponseHeader("cache-control", 0)) |h| h.value else null, - if (conn.getResponseHeader("vary", 0)) |h| h.value else null, + vary, if (conn.getResponseHeader("etag", 0)) |h| h.value else null, if (conn.getResponseHeader("last-modified", 0)) |h| h.value else null, if (conn.getResponseHeader("age", 0)) |h| h.value else null, @@ -1578,17 +1570,32 @@ pub const Transfer = struct { ); if (maybe_cm) |cm| { - var header_list: std.ArrayList(http.Header) = .empty; - var it = transfer.responseHeaderIterator(); - while (it.next()) |hdr| { - try header_list.append(allocator, .{ - .name = try allocator.dupe(u8, hdr.name), - .value = try allocator.dupe(u8, hdr.value), - }); - } - transfer._pending_cache_metadata = cm; - transfer._pending_cache_metadata.?.headers = header_list.items; + + var iter = transfer.responseHeaderIterator(); + var header_list = try iter.collect(allocator); + const end_of_response = header_list.items.len; + transfer._pending_cache_metadata.?.headers = header_list.items[0..end_of_response]; + + if (vary) |vary_str| { + var req_it = transfer.req.headers.iterator(); + + while (req_it.next()) |hdr| { + var vary_iter = std.mem.splitScalar(u8, vary_str, ','); + + while (vary_iter.next()) |part| { + const name = std.mem.trim(u8, part, &std.ascii.whitespace); + if (std.ascii.eqlIgnoreCase(hdr.name, name)) { + try header_list.append(allocator, .{ + .name = try allocator.dupe(u8, hdr.name), + .value = try allocator.dupe(u8, hdr.value), + }); + } + } + } + + transfer._pending_cache_metadata.?.vary_headers = header_list.items[end_of_response..]; + } } } diff --git a/src/network/cache/Cache.zig b/src/network/cache/Cache.zig index c5d9af56..fd20e967 100644 --- a/src/network/cache/Cache.zig +++ b/src/network/cache/Cache.zig @@ -95,23 +95,6 @@ pub const CacheControl = struct { } }; -pub const Vary = union(enum) { - wildcard: void, - value: []const u8, - - pub fn parse(value: []const u8) Vary { - if (std.mem.eql(u8, value, "*")) return .wildcard; - return .{ .value = value }; - } - - pub fn toString(self: Vary) []const u8 { - return switch (self) { - .wildcard => "*", - .value => |v| v, - }; - } -}; - pub const CachedMetadata = struct { url: [:0]const u8, content_type: []const u8, @@ -126,13 +109,17 @@ pub const CachedMetadata = struct { last_modified: ?[]const u8, cache_control: CacheControl, - vary: ?Vary, + /// Response Headers headers: []const Http.Header, + + /// These are Request Headers used by Vary. + vary_headers: []const Http.Header, }; pub const CacheRequest = struct { url: []const u8, timestamp: i64, + request_headers: []const Http.Header, }; pub const CachedData = union(enum) { @@ -166,6 +153,7 @@ pub fn tryCache( if (status != 200) return null; if (has_set_cookie) return null; if (has_authorization) return null; + if (vary) |v| if (std.mem.eql(u8, v, "*")) return null; const cc = CacheControl.parse(cache_control orelse return null) orelse return null; return .{ @@ -175,9 +163,9 @@ pub fn tryCache( .stored_at = timestamp, .age_at_store = if (age) |a| std.fmt.parseInt(u64, a, 10) catch 0 else 0, .cache_control = cc, - .vary = if (vary) |v| Vary.parse(v) else null, .etag = if (etag) |e| try arena.dupe(u8, e) else null, .last_modified = if (last_modified) |lm| try arena.dupe(u8, lm) else null, .headers = &.{}, + .vary_headers = &.{}, }; } diff --git a/src/network/cache/FsCache.zig b/src/network/cache/FsCache.zig index 4d70866d..fef6a4b4 100644 --- a/src/network/cache/FsCache.zig +++ b/src/network/cache/FsCache.zig @@ -154,6 +154,7 @@ pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?Cache.C const metadata = cache_file.metadata; + // Check entry expiration. const now = req.timestamp; const age = (now - metadata.stored_at) + @as(i64, @intCast(metadata.age_at_store)); if (age < 0 or @as(u64, @intCast(age)) >= metadata.cache_control.max_age) { @@ -162,6 +163,28 @@ pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?Cache.C return null; } + // If we have Vary headers, ensure they are present & matching. + for (metadata.vary_headers) |vary_hdr| { + const name = vary_hdr.name; + const value = vary_hdr.value; + + const incoming = for (req.request_headers) |h| { + if (std.ascii.eqlIgnoreCase(h.name, name)) break h.value; + } else ""; + + if (!std.ascii.eqlIgnoreCase(value, incoming)) { + log.debug(.cache, "vary mismatch", .{ .url = req.url, .header = name }); + return null; + } + } + + // On the case of a hash collision. + if (!std.ascii.eqlIgnoreCase(metadata.url, req.url)) { + log.warn(.cache, "collision", .{ .url = req.url, .expected = metadata.url, .got = req.url }); + cleanup = true; + return null; + } + return .{ .metadata = metadata, .data = .{ @@ -243,8 +266,8 @@ test "FsCache: basic put and get" { .etag = null, .last_modified = null, .cache_control = .{ .max_age = 600 }, - .vary = null, .headers = &.{}, + .vary_headers = &.{}, }; const body = "hello world"; @@ -252,7 +275,11 @@ test "FsCache: basic put and get" { const result = cache.get( arena.allocator(), - .{ .url = "https://example.com", .timestamp = now }, + .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{}, + }, ) orelse return error.CacheMiss; const f = result.data.file; const file = f.file; @@ -291,8 +318,8 @@ test "FsCache: get expiration" { .etag = null, .last_modified = null, .cache_control = .{ .max_age = max_age }, - .vary = null, .headers = &.{}, + .vary_headers = &.{}, }; const body = "hello world"; @@ -300,18 +327,30 @@ test "FsCache: get expiration" { const result = cache.get( arena.allocator(), - .{ .url = "https://example.com", .timestamp = now + 50 }, + .{ + .url = "https://example.com", + .timestamp = now + 50, + .request_headers = &.{}, + }, ) orelse return error.CacheMiss; result.data.file.file.close(); try testing.expectEqual(null, cache.get( arena.allocator(), - .{ .url = "https://example.com", .timestamp = now + 200 }, + .{ + .url = "https://example.com", + .timestamp = now + 200, + .request_headers = &.{}, + }, )); try testing.expectEqual(null, cache.get( arena.allocator(), - .{ .url = "https://example.com", .timestamp = now }, + .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{}, + }, )); } @@ -340,8 +379,8 @@ test "FsCache: put override" { .etag = null, .last_modified = null, .cache_control = .{ .max_age = max_age }, - .vary = null, .headers = &.{}, + .vary_headers = &.{}, }; const body = "hello world"; @@ -349,7 +388,11 @@ test "FsCache: put override" { const result = cache.get( arena.allocator(), - .{ .url = "https://example.com", .timestamp = now }, + .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{}, + }, ) orelse return error.CacheMiss; const f = result.data.file; const file = f.file; @@ -378,8 +421,8 @@ test "FsCache: put override" { .etag = null, .last_modified = null, .cache_control = .{ .max_age = max_age }, - .vary = null, .headers = &.{}, + .vary_headers = &.{}, }; const body = "goodbye world"; @@ -387,7 +430,11 @@ test "FsCache: put override" { const result = cache.get( arena.allocator(), - .{ .url = "https://example.com", .timestamp = now }, + .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{}, + }, ) orelse return error.CacheMiss; const f = result.data.file; const file = f.file; @@ -422,6 +469,124 @@ test "FsCache: garbage file" { try testing.expectEqual( null, - setup.cache.get(arena.allocator(), .{ .url = "https://example.com", .timestamp = 5000 }), + setup.cache.get(arena.allocator(), .{ + .url = "https://example.com", + .timestamp = 5000, + .request_headers = &.{}, + }), ); } + +test "FsCache: vary hit and miss" { + var setup = try setupCache(); + defer { + setup.cache.deinit(); + setup.tmp.cleanup(); + } + + const cache = &setup.cache; + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const now = std.time.timestamp(); + const meta = CachedMetadata{ + .url = "https://example.com", + .content_type = "text/html", + .status = 200, + .stored_at = now, + .age_at_store = 0, + .etag = null, + .last_modified = null, + .cache_control = .{ .max_age = 600 }, + .headers = &.{}, + .vary_headers = &.{ + .{ .name = "Accept-Encoding", .value = "gzip" }, + }, + }; + + try cache.put(meta, "hello world"); + + const result = cache.get(arena.allocator(), .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{ + .{ .name = "Accept-Encoding", .value = "gzip" }, + }, + }) orelse return error.CacheMiss; + result.data.file.file.close(); + + try testing.expectEqual(null, cache.get(arena.allocator(), .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{ + .{ .name = "Accept-Encoding", .value = "br" }, + }, + })); + + try testing.expectEqual(null, cache.get(arena.allocator(), .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{}, + })); + + const result2 = cache.get(arena.allocator(), .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{ + .{ .name = "Accept-Encoding", .value = "gzip" }, + }, + }) orelse return error.CacheMiss; + result2.data.file.file.close(); +} + +test "FsCache: vary multiple headers" { + var setup = try setupCache(); + defer { + setup.cache.deinit(); + setup.tmp.cleanup(); + } + + const cache = &setup.cache; + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const now = std.time.timestamp(); + const meta = CachedMetadata{ + .url = "https://example.com", + .content_type = "text/html", + .status = 200, + .stored_at = now, + .age_at_store = 0, + .etag = null, + .last_modified = null, + .cache_control = .{ .max_age = 600 }, + .headers = &.{}, + .vary_headers = &.{ + .{ .name = "Accept-Encoding", .value = "gzip" }, + .{ .name = "Accept-Language", .value = "en" }, + }, + }; + + try cache.put(meta, "hello world"); + + const result = cache.get(arena.allocator(), .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{ + .{ .name = "Accept-Encoding", .value = "gzip" }, + .{ .name = "Accept-Language", .value = "en" }, + }, + }) orelse return error.CacheMiss; + result.data.file.file.close(); + + try testing.expectEqual(null, cache.get(arena.allocator(), .{ + .url = "https://example.com", + .timestamp = now, + .request_headers = &.{ + .{ .name = "Accept-Encoding", .value = "gzip" }, + .{ .name = "Accept-Language", .value = "fr" }, + }, + })); +} diff --git a/src/network/http.zig b/src/network/http.zig index 2bfabac0..6dc217ea 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -79,7 +79,7 @@ pub const Headers = struct { self.headers = updated_headers; } - fn parseHeader(header_str: []const u8) ?Header { + pub fn parseHeader(header_str: []const u8) ?Header { const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); @@ -88,22 +88,9 @@ pub const Headers = struct { return .{ .name = name, .value = value }; } - pub fn iterator(self: *Headers) Iterator { - return .{ - .header = self.headers, - }; + pub fn iterator(self: Headers) HeaderIterator { + return .{ .curl_slist = .{ .header = self.headers } }; } - - const Iterator = struct { - header: [*c]libcurl.CurlSList, - - pub fn next(self: *Iterator) ?Header { - const h = self.header orelse return null; - - self.header = h.*.next; - return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); - } - }; }; // In normal cases, the header iterator comes from the curl linked list. @@ -112,6 +99,7 @@ pub const Headers = struct { // This union, is an iterator that exposes the same API for either case. pub const HeaderIterator = union(enum) { curl: CurlHeaderIterator, + curl_slist: CurlSListIterator, list: ListHeaderIterator, pub fn next(self: *HeaderIterator) ?Header { @@ -120,6 +108,19 @@ pub const HeaderIterator = union(enum) { } } + pub fn collect(self: *HeaderIterator, allocator: std.mem.Allocator) !std.ArrayList(Header) { + var list: std.ArrayList(Header) = .empty; + + while (self.next()) |hdr| { + try list.append(allocator, .{ + .name = try allocator.dupe(u8, hdr.name), + .value = try allocator.dupe(u8, hdr.value), + }); + } + + return list; + } + const CurlHeaderIterator = struct { conn: *const Connection, prev: ?*libcurl.CurlHeader = null, @@ -136,6 +137,16 @@ pub const HeaderIterator = union(enum) { } }; + const CurlSListIterator = struct { + header: [*c]libcurl.CurlSList, + + pub fn next(self: *CurlSListIterator) ?Header { + const h = self.header orelse return null; + self.header = h.*.next; + return Headers.parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); + } + }; + const ListHeaderIterator = struct { index: usize = 0, list: []const Header,