diff --git a/src/App.zig b/src/App.zig index 0b7d5ef09..f7b85b68b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -24,7 +24,6 @@ const Snapshot = @import("browser/js/Snapshot.zig"); const Platform = @import("browser/js/Platform.zig"); const Telemetry = @import("telemetry/telemetry.zig").Telemetry; -const Storage = @import("storage/Storage.zig"); const Network = @import("network/Network.zig"); const Watchdog = @import("Watchdog.zig"); pub const ArenaPool = @import("ArenaPool.zig"); @@ -36,7 +35,6 @@ const App = @This(); network: Network, config: *const Config, -storage: Storage, platform: Platform, snapshot: Snapshot, telemetry: Telemetry, @@ -52,9 +50,6 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { const snapshot = try Snapshot.load(); errdefer snapshot.deinit(); - var storage = try Storage.init(allocator, config); - errdefer storage.deinit(allocator); - const app = try allocator.create(App); errdefer allocator.destroy(app); @@ -63,7 +58,6 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { .allocator = allocator, .platform = platform, .snapshot = snapshot, - .storage = storage, .network = undefined, .app_dir_path = undefined, .telemetry = undefined, @@ -105,7 +99,6 @@ pub fn deinit(self: *App) void { self.snapshot.deinit(); self.platform.deinit(); self.arena_pool.deinit(); - self.storage.deinit(allocator); allocator.destroy(self); } diff --git a/src/Config.zig b/src/Config.zig index 7129f0082..e922ada15 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -24,7 +24,6 @@ const builtin = @import("builtin"); const cli = @import("cli.zig"); const dump = @import("browser/dump.zig"); -const Storage = @import("storage/Storage.zig"); const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; const log = lp.log; @@ -202,8 +201,6 @@ const CommonOptions = .{ .{ .name = "block_urls", .type = ?[]const u8 }, .{ .name = "cookie", .type = ?[]const u8 }, .{ .name = "cookie_jar", .type = ?[]const u8 }, - .{ .name = "storage_engine", .type = ?Storage.EngineType }, - .{ .name = "storage_sqlite_path", .type = ?[:0]const u8 }, .{ .name = "disable_subframes", .type = bool }, .{ .name = "disable_workers", .type = bool }, .{ .name = "enable_external_stylesheets", .type = bool }, @@ -743,20 +740,6 @@ pub fn cdpMaxHTTPMessageSize(self: *const Config) u14 { }; } -pub fn storageEngine(self: *const Config) ?Storage.EngineType { - return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.storage_engine, - else => unreachable, - }; -} - -pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { - return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.storage_sqlite_path, - else => unreachable, - }; -} - /// Returns the user-supplied certificate store (`--ca-cert`/`--ca-path`), /// if any was loaded during argument parsing. The caller takes ownership. pub fn customCertStore(self: *const Config) ?*crypto.X509_STORE { diff --git a/src/network/cache/FsCache.zig b/src/network/cache/FsCache.zig deleted file mode 100644 index e10dc44e4..000000000 --- a/src/network/cache/FsCache.zig +++ /dev/null @@ -1,1000 +0,0 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// 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 . - -const std = @import("std"); -const lp = @import("lightpanda"); - -const Cache = @import("Cache.zig"); - -const log = lp.log; -const CacheRequest = Cache.CacheRequest; -const RenewResponse = Cache.RenewResponse; -const CachedMetadata = Cache.CachedMetadata; -const CachedResponse = Cache.CachedResponse; - -const CACHE_VERSION: usize = 1; -const LOCK_STRIPES = 16; -comptime { - std.debug.assert(std.math.isPowerOfTwo(LOCK_STRIPES)); -} - -pub const FsCache = @This(); - -dir: std.Io.Dir, -locks: [LOCK_STRIPES]std.Io.Mutex = .{std.Io.Mutex.init} ** LOCK_STRIPES, - -const CacheMetadataJson = struct { - version: usize, - metadata: CachedMetadata, -}; - -fn getLockPtr(self: *FsCache, key: *const [HASHED_KEY_LEN]u8) *std.Io.Mutex { - const lock_idx = std.hash.Wyhash.hash(0, key[0..]) & (LOCK_STRIPES - 1); - return &self.locks[lock_idx]; -} - -const BODY_LEN_HEADER_LEN = 8; -const HASHED_KEY_LEN = 64; -const HASHED_PATH_LEN = HASHED_KEY_LEN + 6; -const HASHED_TMP_PATH_LEN = HASHED_PATH_LEN + 4; - -fn hashKey(key: []const u8) [HASHED_KEY_LEN]u8 { - var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(key, &digest, .{}); - var hex: [HASHED_KEY_LEN]u8 = undefined; - _ = std.fmt.bufPrint(&hex, "{s}", .{std.fmt.bytesToHex(&digest, .lower)}) catch unreachable; - return hex; -} - -fn cachePath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_PATH_LEN]u8 { - var path: [HASHED_PATH_LEN]u8 = undefined; - _ = std.fmt.bufPrint(&path, "{s}.cache", .{hashed_key}) catch unreachable; - return path; -} - -fn cacheTmpPath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_TMP_PATH_LEN]u8 { - var path: [HASHED_TMP_PATH_LEN]u8 = undefined; - _ = std.fmt.bufPrint(&path, "{s}.cache.tmp", .{hashed_key}) catch unreachable; - return path; -} - -fn writeCacheFile( - self: *FsCache, - hashed_key: *const [HASHED_KEY_LEN]u8, - body_reader: *std.Io.Reader, - body_len: u64, - meta: CachedMetadata, -) !void { - const cache_p = cachePath(hashed_key); - const cache_tmp_p = cacheTmpPath(hashed_key); - - const file = self.dir.createFile(lp.io, &cache_tmp_p, .{ .truncate = true }) catch |e| { - log.err(.cache, "create file", .{ .url = meta.url, .file = &cache_tmp_p, .err = e }); - return e; - }; - errdefer self.dir.deleteFile(lp.io, &cache_tmp_p) catch {}; - defer file.close(lp.io); - - var writer_buf: [1024]u8 = undefined; - var file_writer = file.writer(lp.io, &writer_buf); - const w = &file_writer.interface; - - var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined; - std.mem.writeInt(u64, &len_buf, body_len, .little); - try w.writeAll(&len_buf); - - var copy_buf: [4096]u8 = undefined; - var remaining = body_len; - while (remaining > 0) { - const to_read = @min(copy_buf.len, remaining); - const n = try body_reader.readSliceShort(copy_buf[0..to_read]); - if (n == 0) break; - try w.writeAll(copy_buf[0..n]); - remaining -= n; - } - - try std.json.Stringify.value( - CacheMetadataJson{ .version = CACHE_VERSION, .metadata = meta }, - .{ .whitespace = .minified }, - w, - ); - try w.flush(); - - self.dir.rename(&cache_tmp_p, self.dir, &cache_p, lp.io) catch |e| { - log.err(.cache, "rename", .{ .url = meta.url, .from = &cache_tmp_p, .to = &cache_p, .err = e }); - return e; - }; -} - -pub fn init(path: []const u8) !FsCache { - const cwd = std.Io.Dir.cwd(); - - try cwd.createDirPath(lp.io, path); - - const dir = try cwd.openDir(lp.io, path, .{ .iterate = true }); - return .{ .dir = dir }; -} - -pub fn deinit(self: *FsCache) void { - self.dir.close(lp.io); -} - -pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?CachedResponse { - const hashed_key = hashKey(req.url); - const cache_p = cachePath(&hashed_key); - - const lock = self.getLockPtr(&hashed_key); - lock.lockUncancelable(lp.io); - defer lock.unlock(lp.io); - - const file = self.dir.openFile(lp.io, &cache_p, .{ .mode = .read_only }) catch |e| { - switch (e) { - std.Io.File.OpenError.FileNotFound => { - log.debug(.cache, "miss", .{ .url = req.url, .hash = &hashed_key, .reason = "missing" }); - }, - else => |err| { - log.warn(.cache, "open file err", .{ .url = req.url, .err = err }); - }, - } - return null; - }; - - var cleanup = false; - defer if (cleanup) { - file.close(lp.io); - self.dir.deleteFile(lp.io, &cache_p) catch |e| { - log.err(.cache, "clean fail", .{ .url = req.url, .file = &cache_p, .err = e }); - }; - }; - - var file_buf: [1024]u8 = undefined; - var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined; - - var file_reader = file.reader(lp.io, &file_buf); - const file_reader_iface = &file_reader.interface; - - file_reader_iface.readSliceAll(&len_buf) catch |e| { - log.warn(.cache, "read header", .{ .url = req.url, .err = e }); - cleanup = true; - return null; - }; - const body_len = std.mem.readInt(u64, &len_buf, .little); - - // Now we read metadata. - file_reader.seekTo(body_len + BODY_LEN_HEADER_LEN) catch |e| { - log.warn(.cache, "seek metadata", .{ .url = req.url, .err = e }); - cleanup = true; - return null; - }; - - var json_reader = std.json.Reader.init(arena, file_reader_iface); - const cache_file: CacheMetadataJson = std.json.parseFromTokenSourceLeaky( - CacheMetadataJson, - arena, - &json_reader, - .{ .allocate = .alloc_always }, - ) catch |e| { - // Warn because malformed metadata can be a deeper symptom. - log.warn(.cache, "miss", .{ .url = req.url, .err = e, .reason = "malformed metadata" }); - cleanup = true; - return null; - }; - - if (cache_file.version != CACHE_VERSION) { - log.debug(.cache, "miss", .{ - .url = req.url, - .reason = "version mismatch", - .expected = CACHE_VERSION, - .got = cache_file.version, - }); - cleanup = true; - return null; - } - - const metadata = cache_file.metadata; - - // 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, "miss", .{ - .url = req.url, - .reason = "vary mismatch", - .header = name, - .expected = value, - .got = incoming, - }); - 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; - } - - // Check entry expiration. - const expired = metadata.isStale(req.timestamp); - log.debug(.cache, "hit", .{ .url = req.url, .hash = &hashed_key, .expired = expired }); - - return .{ - .metadata = metadata, - .data = .{ - .file = .{ - .file = file, - .offset = BODY_LEN_HEADER_LEN, - .len = body_len, - }, - }, - .expired = expired, - }; -} - -pub fn put(self: *FsCache, meta: CachedMetadata, body: []const u8) !void { - const hashed_key = hashKey(meta.url); - - const lock = self.getLockPtr(&hashed_key); - lock.lockUncancelable(lp.io); - defer lock.unlock(lp.io); - - var body_reader = std.Io.Reader.fixed(body); - try self.writeCacheFile(&hashed_key, &body_reader, body.len, meta); - - log.debug(.cache, "put", .{ .url = meta.url, .hash = &hashed_key, .body_len = body.len }); -} - -pub fn clear(self: *FsCache) !void { - for (&self.locks) |*lock| lock.lockUncancelable(lp.io); - defer for (&self.locks) |*lock| lock.unlock(lp.io); - - var iter = self.dir.iterate(); - while (try iter.next(lp.io)) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.name, ".cache") and - !std.mem.endsWith(u8, entry.name, ".cache.tmp")) continue; - - self.dir.deleteFile(lp.io, entry.name) catch |e| { - log.err(.cache, "clear delete fail", .{ .file = entry.name, .err = e }); - }; - } -} - -pub fn evict(self: *FsCache, url: []const u8) void { - const hashed_key = hashKey(url); - const cache_p = cachePath(&hashed_key); - - const lock = self.getLockPtr(&hashed_key); - lock.lockUncancelable(lp.io); - defer lock.unlock(lp.io); - - self.dir.deleteFile(lp.io, &cache_p) catch |e| switch (e) { - error.FileNotFound => {}, - else => log.warn(.cache, "evict failed", .{ .url = url, .err = e }), - }; -} - -pub fn renew(self: *FsCache, arena: std.mem.Allocator, req: RenewResponse) !void { - const hashed_key = hashKey(req.url); - const cache_p = cachePath(&hashed_key); - - const lock = self.getLockPtr(&hashed_key); - lock.lockUncancelable(lp.io); - defer lock.unlock(lp.io); - - const file = self.dir.openFile(lp.io, &cache_p, .{ .mode = .read_only }) catch |e| { - log.warn(.cache, "renew open failed", .{ .url = req.url, .err = e }); - return e; - }; - defer file.close(lp.io); - - var file_buf: [1024]u8 = undefined; - var file_reader = file.reader(lp.io, &file_buf); - const r = &file_reader.interface; - - var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined; - r.readSliceAll(&len_buf) catch |e| { - log.warn(.cache, "renew read len", .{ .url = req.url, .err = e }); - return e; - }; - const body_len = std.mem.readInt(u64, &len_buf, .little); - - try file_reader.seekTo(BODY_LEN_HEADER_LEN + body_len); - - var json_reader = std.json.Reader.init(arena, r); - var parsed: CacheMetadataJson = std.json.parseFromTokenSourceLeaky( - CacheMetadataJson, - arena, - &json_reader, - .{ .allocate = .alloc_always }, - ) catch |e| { - log.warn(.cache, "renew parse", .{ .url = req.url, .err = e }); - return e; - }; - - parsed.metadata.renew(req); - try file_reader.seekTo(BODY_LEN_HEADER_LEN); - - self.writeCacheFile(&hashed_key, r, body_len, parsed.metadata) catch |e| { - log.warn(.cache, "renew write", .{ .url = req.url, .err = e }); - return e; - }; - - log.debug(.cache, "renewed", .{ .url = req.url }); -} - -const testing = std.testing; - -fn setupCache() !struct { tmp: testing.TmpDir, cache: Cache } { - var tmp = testing.tmpDir(.{}); - errdefer tmp.cleanup(); - - const path = try tmp.dir.realPathFileAlloc(lp.io, ".", testing.allocator); - defer testing.allocator.free(path); - - return .{ - .tmp = tmp, - .cache = Cache{ .kind = .{ .fs = try FsCache.init(path) } }, - }; -} - -test "FsCache: basic put and get" { - 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.Io.Clock.now(.real, lp.io).toSeconds(); - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = 600 }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - const body = "hello world"; - try cache.put(meta, body); - - const result = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - const f = result.data.file; - const file = f.file; - defer file.close(lp.io); - - var buf: [64]u8 = undefined; - var file_reader = file.reader(lp.io, &buf); - try file_reader.seekTo(f.offset); - - const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len); - defer testing.allocator.free(read_buf); - try testing.expectEqualStrings(body, read_buf); -} - -test "FsCache: get expiration" { - 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 = 5000; - const max_age = 1000; - - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 900, - .cache_control = .{ .max_age = max_age }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - const body = "hello world"; - try cache.put(meta, body); - - const result = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now + 50, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - result.data.file.file.close(lp.io); - - // Expired: age = 200 + 900 = 1100 >= 1000 - const stale = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now + 200, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - defer stale.data.file.file.close(lp.io); - try testing.expectEqual(true, stale.expired); -} - -test "FsCache: put override" { - 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 = 5000; - const max_age = 1000; - - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 900, - .cache_control = .{ .max_age = max_age }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - const body = "hello world"; - try cache.put(meta, body); - - const result = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - const f = result.data.file; - const file = f.file; - defer file.close(lp.io); - - var buf: [64]u8 = undefined; - var file_reader = file.reader(lp.io, &buf); - try file_reader.seekTo(f.offset); - - const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len); - defer testing.allocator.free(read_buf); - - try testing.expectEqualStrings(body, read_buf); - } - - { - const now = 10000; - const max_age = 2000; - - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = max_age }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - const body = "goodbye world"; - try cache.put(meta, body); - - const result = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - const f = result.data.file; - const file = f.file; - defer file.close(lp.io); - - var buf: [64]u8 = undefined; - var file_reader = file.reader(lp.io, &buf); - try file_reader.seekTo(f.offset); - - const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len); - defer testing.allocator.free(read_buf); - - try testing.expectEqualStrings(body, read_buf); - } -} - -test "FsCache: garbage file" { - const LogFilter = @import("../../testing.zig").LogFilter; - const filter: LogFilter = .init(&.{.cache}); - defer filter.deinit(); - - var setup = try setupCache(); - defer { - setup.cache.deinit(); - setup.tmp.cleanup(); - } - - const hashed_key = hashKey("https://example.com"); - const cache_p = cachePath(&hashed_key); - const file = try setup.cache.kind.fs.dir.createFile(lp.io, &cache_p, .{}); - try file.writeStreamingAll(lp.io, "this is not a valid cache file !@#$%"); - file.close(lp.io); - - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - try testing.expectEqual( - null, - 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.Io.Clock.now(.real, lp.io).toSeconds(); - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .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(lp.io); - - 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(lp.io); -} - -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.Io.Clock.now(.real, lp.io).toSeconds(); - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .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(lp.io); - - 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" }, - }, - })); -} - -test "FsCache: clear removes all entries" { - 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.Io.Clock.now(.real, lp.io).toSeconds(); - const base_meta_a = CachedMetadata{ - .url = "https://example.com/a", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = 600 }, - .headers = &.{}, - .vary_headers = &.{}, - .content_type = "text/html", - }; - - const base_meta_b = CachedMetadata{ - .url = "https://example.com/b", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = 600 }, - .headers = &.{}, - .vary_headers = &.{}, - .content_type = "text/html", - }; - - try cache.put(base_meta_a, "body a"); - try cache.put(base_meta_b, "body b"); - - // Sanity check: both are cached - const r1 = cache.get( - arena.allocator(), - .{ - .url = "https://example.com/a", - .timestamp = now, - .request_headers = &.{}, - }, - ); - try testing.expect(r1 != null); - r1.?.data.file.file.close(lp.io); - - const r2 = cache.get( - arena.allocator(), - .{ - .url = "https://example.com/b", - .timestamp = now, - .request_headers = &.{}, - }, - ); - try testing.expect(r2 != null); - r2.?.data.file.file.close(lp.io); - - try cache.clear(); - - try testing.expectEqual(null, cache.get( - arena.allocator(), - .{ - .url = "https://example.com/a", - .timestamp = now, - .request_headers = &.{}, - }, - )); - try testing.expectEqual(null, cache.get( - arena.allocator(), - .{ - .url = "https://example.com/b", - .timestamp = now, - .request_headers = &.{}, - }, - )); -} - -test "FsCache: put after clear works" { - 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.Io.Clock.now(.real, lp.io).toSeconds(); - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = 600 }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - try cache.put(meta, "before clear"); - try cache.clear(); - - // Should be a miss after clear - try testing.expectEqual( - null, - cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now, - .request_headers = &.{}, - }, - ), - ); - - // Put again after clear — should work normally - try cache.put(meta, "after clear"); - const result = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - const f = result.data.file; - defer f.file.close(lp.io); - - var buf: [64]u8 = undefined; - var file_reader = f.file.reader(lp.io, &buf); - try file_reader.seekTo(f.offset); - const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len); - defer testing.allocator.free(read_buf); - try testing.expectEqualStrings("after clear", read_buf); -} - -test "FsCache: evict removes entry" { - 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.Io.Clock.now(.real, lp.io).toSeconds(); - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = 600 }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - try cache.put(meta, "hello world"); - - const result = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - result.data.file.file.close(lp.io); - - cache.evict("https://example.com"); - - try testing.expectEqual(null, cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now, - .request_headers = &.{}, - }, - )); -} - -test "FsCache: renew refreshes expiry" { - 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: i64 = 5000; - const max_age: u64 = 1000; - - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = max_age }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - try cache.put(meta, "hello world"); - - // renew while still fresh at now+500 - try cache.renew( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now + 500, - .headers = &.{}, - }, - ); - - // Without revalidation would expire at now+1000, but clock reset to now+500 - // so still fresh at now+1200 - const r1 = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now + 1200, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - r1.data.file.file.close(lp.io); - - // Expires at now+500+1000 = now+1500 - const stale1 = cache.get( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now + 1500, - .request_headers = &.{}, - }, - ) orelse return error.CacheMiss; - stale1.data.file.file.close(lp.io); - try testing.expectEqual(true, stale1.expired); -} - -test "FsCache: renew preserves body" { - 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.Io.Clock.now(.real, lp.io).toSeconds(); - const meta = CachedMetadata{ - .url = "https://example.com", - .content_type = "text/html", - .status = 200, - .stored_at = now, - .age_at_store = 0, - .cache_control = .{ .max_age = 600 }, - .headers = &.{}, - .vary_headers = &.{}, - }; - - const body = "original body"; - try cache.put(meta, body); - - try cache.renew( - arena.allocator(), - .{ - .url = "https://example.com", - .timestamp = now + 100, - .headers = &.{}, - }, - ); - - const result = cache.get(arena.allocator(), .{ - .url = "https://example.com", - .timestamp = now + 100, - .request_headers = &.{}, - }) orelse return error.CacheMiss; - - const f = result.data.file; - defer f.file.close(lp.io); - - var buf: [64]u8 = undefined; - var file_reader = f.file.reader(lp.io, &buf); - try file_reader.seekTo(f.offset); - const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len); - defer testing.allocator.free(read_buf); - try testing.expectEqualStrings(body, read_buf); -} diff --git a/src/storage/Blackhole.zig b/src/storage/Blackhole.zig deleted file mode 100644 index 8da0dc8e5..000000000 --- a/src/storage/Blackhole.zig +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// 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 . - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const Blackhole = @This(); - -pub fn deinit(_: *Blackhole, _: Allocator) void {} diff --git a/src/storage/Storage.zig b/src/storage/Storage.zig deleted file mode 100644 index 54ac21f3d..000000000 --- a/src/storage/Storage.zig +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// 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 . - -const std = @import("std"); -const log = @import("../log.zig"); -const Config = @import("../Config.zig"); -const Blackhole = @import("Blackhole.zig"); -const Sqlite = @import("sqlite/Sqlite.zig"); - -const Allocator = std.mem.Allocator; - -const Storage = @This(); - -pub const EngineType = enum { - none, -}; - -const Engine = union(EngineType) { - none: Blackhole, -}; - -engine: Engine, - -pub fn init(allocator: Allocator, config: *const Config) !Storage { - const engine_type = config.storageEngine() orelse .none; - const engine = initEngine(allocator, engine_type, config) catch |err| { - log.fatal(.storage, "storage setup", .{ .engine = engine_type, .err = err }); - return err; - }; - - return .{ - .engine = engine, - }; -} - -fn initEngine(_: Allocator, engine_type: EngineType, _: *const Config) !Engine { - switch (engine_type) { - .none => return .{ .none = Blackhole{} }, - } -} - -pub fn deinit(self: *Storage, allocator: Allocator) void { - switch (self.engine) { - inline else => |*engine| engine.deinit(allocator), - } -}