mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 01:36:15 -04:00
525 lines
16 KiB
Zig
525 lines
16 KiB
Zig
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
const Http = @import("../http.zig");
|
|
const SqliteCache = @import("SqliteCache.zig");
|
|
|
|
const log = lp.log;
|
|
|
|
/// A browser-wide cache for resources across the network.
|
|
/// This mostly conforms to RFC9111 with regards to caching behavior.
|
|
pub const Cache = @This();
|
|
|
|
kind: union(enum) {
|
|
sqlite: SqliteCache,
|
|
},
|
|
|
|
pub fn deinit(self: *Cache) void {
|
|
return switch (self.kind) {
|
|
inline else => |*c| c.deinit(),
|
|
};
|
|
}
|
|
|
|
pub fn get(self: *Cache, arena: std.mem.Allocator, req: CacheRequest) !CacheGetResult {
|
|
return switch (self.kind) {
|
|
inline else => |*c| c.get(arena, req),
|
|
};
|
|
}
|
|
|
|
pub fn put(self: *Cache, metadata: CachedMetadata, body: []const u8) !void {
|
|
return switch (self.kind) {
|
|
inline else => |*c| c.put(metadata, body),
|
|
};
|
|
}
|
|
|
|
pub fn evict(self: *Cache, url: []const u8) void {
|
|
return switch (self.kind) {
|
|
inline else => |*c| c.evict(url),
|
|
};
|
|
}
|
|
|
|
pub fn renew(self: *Cache, arena: std.mem.Allocator, req: RenewResponse) !void {
|
|
return switch (self.kind) {
|
|
inline else => |*c| c.renew(arena, req),
|
|
};
|
|
}
|
|
|
|
pub fn clear(self: *Cache) !void {
|
|
return switch (self.kind) {
|
|
inline else => |*c| c.clear(),
|
|
};
|
|
}
|
|
|
|
/// RFC 9111 delta-seconds values larger than this are capped rather than
|
|
/// rejected (§1.2.2). Capping also keeps the value safely castable to i64
|
|
/// for freshness arithmetic and storage.
|
|
const max_delta_seconds: u64 = 2147483648;
|
|
|
|
pub fn parseDeltaSeconds(value: []const u8) ?u64 {
|
|
const seconds = std.fmt.parseInt(u64, value, 10) catch |err| switch (err) {
|
|
error.Overflow => return max_delta_seconds,
|
|
error.InvalidCharacter => return null,
|
|
};
|
|
return @min(seconds, max_delta_seconds);
|
|
}
|
|
|
|
pub const CacheControl = struct {
|
|
max_age: u64,
|
|
must_revalidate: bool = false,
|
|
|
|
pub fn parse(value: []const u8) ?CacheControl {
|
|
var cc: CacheControl = .{ .max_age = undefined };
|
|
|
|
var max_age_set = false;
|
|
var max_s_age_set = false;
|
|
|
|
var iter = std.mem.splitScalar(u8, value, ',');
|
|
while (iter.next()) |part| {
|
|
const directive = std.mem.trim(u8, part, &std.ascii.whitespace);
|
|
|
|
if (std.ascii.eqlIgnoreCase(directive, "no-store")) {
|
|
return null;
|
|
}
|
|
if (std.ascii.eqlIgnoreCase(directive, "no-cache")) {
|
|
if (!max_age_set) {
|
|
cc.max_age = 0;
|
|
max_age_set = true;
|
|
}
|
|
|
|
cc.must_revalidate = true;
|
|
continue;
|
|
}
|
|
if (std.ascii.eqlIgnoreCase(directive, "private")) {
|
|
return null;
|
|
}
|
|
|
|
if (std.ascii.startsWithIgnoreCase(directive, "max-age=")) {
|
|
if (!max_s_age_set) {
|
|
if (parseDeltaSeconds(directive[8..])) |max_age| {
|
|
cc.max_age = max_age;
|
|
max_age_set = true;
|
|
}
|
|
}
|
|
} else if (std.ascii.startsWithIgnoreCase(directive, "s-maxage=")) {
|
|
if (parseDeltaSeconds(directive[9..])) |max_age| {
|
|
cc.max_age = max_age;
|
|
max_age_set = true;
|
|
max_s_age_set = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!max_age_set) return null;
|
|
if (cc.max_age == 0 and !cc.must_revalidate) return null;
|
|
|
|
return cc;
|
|
}
|
|
};
|
|
|
|
pub const CachedMetadata = struct {
|
|
url: [:0]const u8,
|
|
content_type: []const u8,
|
|
|
|
status: u16,
|
|
stored_at: i64,
|
|
age_at_store: u64,
|
|
|
|
cache_control: CacheControl,
|
|
/// Response Headers
|
|
headers: []const Http.Header,
|
|
/// These are Request Headers used by Vary.
|
|
vary_headers: []const Http.Header,
|
|
|
|
// Validators for conditional requests.
|
|
etag: ?[]const u8 = null,
|
|
last_modified: ?[]const u8 = null,
|
|
|
|
pub fn format(self: CachedMetadata, writer: *std.Io.Writer) !void {
|
|
try writer.print("url={s} | status={d} | content_type={s} | max_age={d} | etag={s} | last-modified={s} | vary=[", .{
|
|
self.url,
|
|
self.status,
|
|
self.content_type,
|
|
self.cache_control.max_age,
|
|
self.etag orelse "null",
|
|
self.last_modified orelse "null",
|
|
});
|
|
|
|
// Logging all headers gets pretty verbose...
|
|
// so we just log the Vary ones that matter for caching.
|
|
|
|
if (self.vary_headers.len > 0) {
|
|
for (self.vary_headers, 0..) |hdr, i| {
|
|
if (i > 0) try writer.print(", ", .{});
|
|
try writer.print("{s}: {s}", .{ hdr.name, hdr.value });
|
|
}
|
|
}
|
|
try writer.print("]", .{});
|
|
}
|
|
|
|
pub fn isStale(self: CachedMetadata, timestamp: i64) bool {
|
|
if (self.cache_control.must_revalidate) return true;
|
|
const age = (timestamp - self.stored_at) + @as(i64, @intCast(self.age_at_store));
|
|
return age >= @as(i64, @intCast(self.cache_control.max_age));
|
|
}
|
|
|
|
pub fn hasValidators(self: CachedMetadata) bool {
|
|
return self.etag != null or self.last_modified != null;
|
|
}
|
|
|
|
pub fn renew(self: *CachedMetadata, req: RenewResponse) void {
|
|
self.stored_at = req.timestamp;
|
|
self.age_at_store = 0;
|
|
|
|
for (req.headers) |h| {
|
|
const name = h.name;
|
|
const value = h.value;
|
|
|
|
if (std.ascii.eqlIgnoreCase("Age", name)) {
|
|
self.age_at_store = parseDeltaSeconds(value) orelse 0;
|
|
} else if (std.ascii.eqlIgnoreCase("Cache-Control", name)) {
|
|
self.cache_control = CacheControl.parse(value) orelse continue;
|
|
} else if (std.ascii.eqlIgnoreCase("ETag", name)) {
|
|
self.etag = value;
|
|
} else if (std.ascii.eqlIgnoreCase("Last-Modified", name)) {
|
|
self.last_modified = value;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
pub const CacheRequest = struct {
|
|
url: []const u8,
|
|
timestamp: i64,
|
|
request_headers: []const Http.Header,
|
|
};
|
|
|
|
pub const RenewResponse = struct {
|
|
url: []const u8,
|
|
timestamp: i64,
|
|
headers: []const Http.Header,
|
|
};
|
|
|
|
pub const CachedData = union(enum) {
|
|
buffer: []const u8,
|
|
|
|
pub fn deinit(self: CachedData) void {
|
|
switch (self) {
|
|
.buffer => {},
|
|
}
|
|
}
|
|
|
|
pub fn format(self: CachedData, writer: *std.Io.Writer) !void {
|
|
switch (self) {
|
|
.buffer => |buf| try writer.print("buffer({d} bytes)", .{buf.len}),
|
|
}
|
|
}
|
|
};
|
|
|
|
pub const CacheGetResult = union(enum) {
|
|
// Fresh / Usable as is.
|
|
hit: CachedResponse,
|
|
/// Stale but has proper revalidators. Caller should make a conditional request and then
|
|
/// renew or put depending on Response.
|
|
revalidate: CachedResponse,
|
|
/// Cache Miss.
|
|
miss,
|
|
/// Stale entry with no revalidators. Must call `evict()` and should be treated as a miss.
|
|
stale,
|
|
};
|
|
|
|
pub const CachedResponse = struct {
|
|
metadata: CachedMetadata,
|
|
data: CachedData,
|
|
|
|
pub fn format(self: *const CachedResponse, writer: *std.Io.Writer) !void {
|
|
try writer.print("metadata=(", .{});
|
|
try self.metadata.format(writer);
|
|
try writer.print("), data=", .{});
|
|
try self.data.format(writer);
|
|
}
|
|
};
|
|
|
|
pub fn tryCache(
|
|
arena: std.mem.Allocator,
|
|
timestamp: i64,
|
|
url: [:0]const u8,
|
|
status: u16,
|
|
content_type: ?[]const u8,
|
|
cache_control: ?[]const u8,
|
|
vary: ?[]const u8,
|
|
age: ?[]const u8,
|
|
etag: ?[]const u8,
|
|
last_modified: ?[]const u8,
|
|
has_set_cookie: bool,
|
|
has_authorization: bool,
|
|
) !?CachedMetadata {
|
|
if (status != 200) {
|
|
log.debug(.cache, "no store", .{ .url = url, .code = status, .reason = "status" });
|
|
return null;
|
|
}
|
|
if (has_set_cookie) {
|
|
log.debug(.cache, "no store", .{ .url = url, .reason = "has_cookies" });
|
|
return null;
|
|
}
|
|
if (has_authorization) {
|
|
log.debug(.cache, "no store", .{ .url = url, .reason = "has_authorization" });
|
|
return null;
|
|
}
|
|
if (vary) |v| if (std.mem.eql(u8, v, "*")) {
|
|
log.debug(.cache, "no store", .{ .url = url, .vary = v, .reason = "vary" });
|
|
return null;
|
|
};
|
|
|
|
const cc = blk: {
|
|
if (cache_control) |c| {
|
|
if (CacheControl.parse(c)) |cc| {
|
|
break :blk cc;
|
|
}
|
|
} else if (last_modified != null) {
|
|
// Requires Last-Modified to be present to heuristically cache.
|
|
log.debug(.cache, "heuristic cache", .{ .url = url, .max_age = 86400 });
|
|
break :blk CacheControl{ .max_age = 86400, .must_revalidate = false };
|
|
}
|
|
|
|
log.debug(.cache, "no store", .{
|
|
.url = url,
|
|
.cache_control = cache_control orelse "null",
|
|
.last_modified = last_modified orelse "null",
|
|
});
|
|
return null;
|
|
};
|
|
|
|
return .{
|
|
.url = try arena.dupeZ(u8, url),
|
|
.content_type = if (content_type) |ct| try arena.dupe(u8, ct) else "application/octet-stream",
|
|
.status = status,
|
|
.stored_at = timestamp,
|
|
.age_at_store = if (age) |a| parseDeltaSeconds(a) orelse 0 else 0,
|
|
.cache_control = cc,
|
|
.headers = &.{},
|
|
.vary_headers = &.{},
|
|
.etag = if (etag) |e| try arena.dupe(u8, e) else null,
|
|
.last_modified = if (last_modified) |lm| try arena.dupe(u8, lm) else null,
|
|
};
|
|
}
|
|
const testing = @import("../../testing.zig");
|
|
|
|
test "Cache: CacheControl.parse" {
|
|
try testing.expectEqual(300, CacheControl.parse("max-age=300").?.max_age);
|
|
|
|
try testing.expectEqual(300, CacheControl.parse("Max-Age=300").?.max_age);
|
|
try testing.expectEqual(300, CacheControl.parse("MAX-AGE=300").?.max_age);
|
|
|
|
try testing.expectEqual(300, CacheControl.parse("public, max-age=300").?.max_age);
|
|
try testing.expectEqual(300, CacheControl.parse(" max-age=300 ").?.max_age);
|
|
|
|
try testing.expectEqual(600, CacheControl.parse("max-age=300, s-maxage=600").?.max_age);
|
|
try testing.expectEqual(600, CacheControl.parse("s-maxage=600, max-age=300").?.max_age);
|
|
|
|
try testing.expectEqual(null, CacheControl.parse("no-store"));
|
|
try testing.expectEqual(
|
|
CacheControl{ .max_age = 0, .must_revalidate = true },
|
|
CacheControl.parse("no-cache"),
|
|
);
|
|
try testing.expectEqual(null, CacheControl.parse("private"));
|
|
try testing.expectEqual(null, CacheControl.parse("max-age=300, no-store"));
|
|
try testing.expectEqual(
|
|
CacheControl{ .max_age = 300, .must_revalidate = true },
|
|
CacheControl.parse("no-cache, max-age=300"),
|
|
);
|
|
try testing.expectEqual(null, CacheControl.parse("Private, max-age=300"));
|
|
|
|
try testing.expectEqual(null, CacheControl.parse("max-age=0"));
|
|
|
|
try testing.expectEqual(null, CacheControl.parse("public"));
|
|
try testing.expectEqual(null, CacheControl.parse(""));
|
|
|
|
try testing.expectEqual(null, CacheControl.parse("max-age=abc"));
|
|
try testing.expectEqual(null, CacheControl.parse("max-age="));
|
|
|
|
// values longer than 8 digits must not be truncated
|
|
try testing.expectEqual(315360000, CacheControl.parse("max-age=315360000").?.max_age);
|
|
|
|
// delta-seconds too large to represent are capped at 2^31 (RFC 9111 §1.2.2)
|
|
try testing.expectEqual(max_delta_seconds, CacheControl.parse("max-age=2147483649").?.max_age);
|
|
try testing.expectEqual(max_delta_seconds, CacheControl.parse("max-age=9999999999999999999").?.max_age);
|
|
try testing.expectEqual(max_delta_seconds, CacheControl.parse("max-age=99999999999999999999999").?.max_age);
|
|
try testing.expectEqual(max_delta_seconds, CacheControl.parse("s-maxage=9999999999999999999").?.max_age);
|
|
}
|
|
|
|
test "Cache: CachedMetadata.renew updates timestamp and age" {
|
|
var meta = CachedMetadata{
|
|
.url = "https://example.com",
|
|
.content_type = "text/html",
|
|
.status = 200,
|
|
.stored_at = 1000,
|
|
.age_at_store = 50,
|
|
.cache_control = .{ .max_age = 600 },
|
|
.headers = &.{},
|
|
.vary_headers = &.{},
|
|
};
|
|
|
|
meta.renew(.{ .url = "https://example.com", .timestamp = 2000, .headers = &.{} });
|
|
|
|
try testing.expectEqual(2000, meta.stored_at);
|
|
try testing.expectEqual(0, meta.age_at_store);
|
|
}
|
|
|
|
test "Cache: CachedMetadata.renew updates age from Age header" {
|
|
var meta = CachedMetadata{
|
|
.url = "https://example.com",
|
|
.content_type = "text/html",
|
|
.status = 200,
|
|
.stored_at = 1000,
|
|
.age_at_store = 0,
|
|
.cache_control = .{ .max_age = 600 },
|
|
.headers = &.{},
|
|
.vary_headers = &.{},
|
|
};
|
|
|
|
meta.renew(.{
|
|
.url = "https://example.com",
|
|
.timestamp = 2000,
|
|
.headers = &.{.{ .name = "Age", .value = "42" }},
|
|
});
|
|
|
|
try testing.expectEqual(42, meta.age_at_store);
|
|
|
|
meta.renew(.{
|
|
.url = "https://example.com",
|
|
.timestamp = 2000,
|
|
.headers = &.{.{ .name = "Age", .value = "18446744073709551615" }},
|
|
});
|
|
|
|
try testing.expectEqual(max_delta_seconds, meta.age_at_store);
|
|
}
|
|
|
|
test "Cache: CachedMetadata.renew updates cache_control" {
|
|
var meta = CachedMetadata{
|
|
.url = "https://example.com",
|
|
.content_type = "text/html",
|
|
.status = 200,
|
|
.stored_at = 1000,
|
|
.age_at_store = 0,
|
|
.cache_control = .{ .max_age = 600 },
|
|
.headers = &.{},
|
|
.vary_headers = &.{},
|
|
};
|
|
|
|
meta.renew(.{
|
|
.url = "https://example.com",
|
|
.timestamp = 2000,
|
|
.headers = &.{.{ .name = "Cache-Control", .value = "max-age=1200" }},
|
|
});
|
|
|
|
try testing.expectEqual(1200, meta.cache_control.max_age);
|
|
}
|
|
|
|
test "Cache: CachedMetadata.renew preserves cache_control on invalid header" {
|
|
var meta = CachedMetadata{
|
|
.url = "https://example.com",
|
|
.content_type = "text/html",
|
|
.status = 200,
|
|
.stored_at = 1000,
|
|
.age_at_store = 0,
|
|
.cache_control = .{ .max_age = 600 },
|
|
.headers = &.{},
|
|
.vary_headers = &.{},
|
|
};
|
|
|
|
meta.renew(.{
|
|
.url = "https://example.com",
|
|
.timestamp = 2000,
|
|
.headers = &.{.{ .name = "Cache-Control", .value = "no-store" }},
|
|
});
|
|
|
|
try testing.expectEqual(600, meta.cache_control.max_age);
|
|
}
|
|
|
|
test "Cache: CachedMetadata.renew updates etag and last_modified" {
|
|
var meta = CachedMetadata{
|
|
.url = "https://example.com",
|
|
.content_type = "text/html",
|
|
.status = 200,
|
|
.stored_at = 1000,
|
|
.age_at_store = 0,
|
|
.cache_control = .{ .max_age = 600 },
|
|
.headers = &.{},
|
|
.vary_headers = &.{},
|
|
.etag = "\"old-etag\"",
|
|
.last_modified = "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
};
|
|
|
|
meta.renew(.{
|
|
.url = "https://example.com",
|
|
.timestamp = 2000,
|
|
.headers = &.{
|
|
.{ .name = "ETag", .value = "\"new-etag\"" },
|
|
.{ .name = "Last-Modified", .value = "Tue, 02 Jan 2024 00:00:00 GMT" },
|
|
},
|
|
});
|
|
|
|
try testing.expectEqualSlices(u8, "\"new-etag\"", meta.etag.?);
|
|
try testing.expectEqualSlices(u8, "Tue, 02 Jan 2024 00:00:00 GMT", meta.last_modified.?);
|
|
}
|
|
|
|
test "Cache: tryCache heuristic when no cache-control" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
|
|
const result = try tryCache(
|
|
arena.allocator(),
|
|
1000,
|
|
"https://example.com",
|
|
200,
|
|
"text/html",
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
false,
|
|
false,
|
|
);
|
|
try testing.expectEqual(null, result);
|
|
}
|
|
|
|
test "Cache: tryCache heuristic when no cache-control with last-modified" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
|
|
const result = try tryCache(
|
|
arena.allocator(),
|
|
1000,
|
|
"https://example.com",
|
|
200,
|
|
"text/html",
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
"Wed, 21 Oct 2015 07:28:00 GMT",
|
|
false,
|
|
false,
|
|
);
|
|
try testing.expectEqual(@as(u64, 86400), result.?.cache_control.max_age);
|
|
try testing.expectEqual(false, result.?.cache_control.must_revalidate);
|
|
}
|