From e76da3d156f28de4052fa5a479d0762652f9fc2f Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 27 Jul 2026 18:22:50 -0500 Subject: [PATCH] Cache OAuth client documents --- src/atproto/oauth.zig | 12 +++- src/internal/document_cache.zig | 115 ++++++++++++++++++++++++++++++++ src/root.zig | 2 + 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/internal/document_cache.zig diff --git a/src/atproto/oauth.zig b/src/atproto/oauth.zig index 8631387..a5de7ac 100644 --- a/src/atproto/oauth.zig +++ b/src/atproto/oauth.zig @@ -2,6 +2,7 @@ const std = @import("std"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const dpop = @import("../internal/dpop.zig"); +const document_cache = @import("../internal/document_cache.zig"); const log = @import("../core/log.zig"); const http_api = @import("../http/api.zig"); const permission_sets = @import("oauth/permission_sets.zig"); @@ -14,6 +15,9 @@ const par_expires_in: i64 = 600; const access_token_expires_in: i64 = 15 * 60; const refresh_token_expires_in: i64 = 14 * 24 * 60 * 60; const previous_refresh_grace_in: i64 = 30; +const client_document_ttl_seconds: i64 = 10 * 60; + +var client_document_cache = document_cache.DocumentCache(64).init(std.heap.page_allocator); const ClientDisplayInfo = struct { name: []const u8, @@ -611,6 +615,10 @@ fn fetchClientMetadataForAuth(request: *http_api.Request, allocator: std.mem.All } fn fetchJson(allocator: std.mem.Allocator, url: []const u8, max_response_size: usize) !std.json.Parsed(std.json.Value) { + if (try client_document_cache.get(allocator, url, now())) |body| { + return try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + } + var transport = zat.HttpTransport.init(store.currentIo(), allocator); defer transport.deinit(); const result = try transport.fetch(.{ @@ -621,7 +629,9 @@ fn fetchJson(allocator: std.mem.Allocator, url: []const u8, max_response_size: u }); const status_code = @intFromEnum(result.status); if (status_code < 200 or status_code >= 300) return error.HttpStatus; - return std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); + client_document_cache.put(url, result.body, now() + client_document_ttl_seconds) catch {}; + return parsed; } fn verifyClientAssertion(allocator: std.mem.Allocator, metadata: std.json.Value, client_id: []const u8, assertion: []const u8) !void { diff --git a/src/internal/document_cache.zig b/src/internal/document_cache.zig new file mode 100644 index 0000000..6cb4fbd --- /dev/null +++ b/src/internal/document_cache.zig @@ -0,0 +1,115 @@ +const std = @import("std"); + +pub fn DocumentCache(comptime capacity: usize) type { + if (capacity == 0) @compileError("DocumentCache requires at least one entry"); + + return struct { + const Self = @This(); + + const Entry = struct { + url: []u8, + body: []u8, + expires_at: i64, + }; + + allocator: std.mem.Allocator, + mutex: std.atomic.Mutex = .unlocked, + entries: [capacity]?Entry = .{null} ** capacity, + next: usize = 0, + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Self) void { + self.lock(); + defer self.mutex.unlock(); + for (&self.entries) |*slot| self.freeEntry(slot); + } + + pub fn get(self: *Self, allocator: std.mem.Allocator, url: []const u8, now: i64) !?[]u8 { + self.lock(); + defer self.mutex.unlock(); + + for (&self.entries) |*slot| { + const entry = slot.* orelse continue; + if (!std.mem.eql(u8, entry.url, url)) continue; + if (entry.expires_at <= now) { + self.freeEntry(slot); + return null; + } + return try allocator.dupe(u8, entry.body); + } + return null; + } + + pub fn put(self: *Self, url: []const u8, body: []const u8, expires_at: i64) !void { + const owned_url = try self.allocator.dupe(u8, url); + errdefer self.allocator.free(owned_url); + const owned_body = try self.allocator.dupe(u8, body); + errdefer self.allocator.free(owned_body); + + self.lock(); + defer self.mutex.unlock(); + + var index: ?usize = null; + for (&self.entries, 0..) |*slot, i| { + if (slot.* == null) { + if (index == null) index = i; + continue; + } + if (std.mem.eql(u8, slot.*.?.url, url)) { + index = i; + break; + } + } + + const selected = index orelse self.next; + self.freeEntry(&self.entries[selected]); + self.entries[selected] = .{ + .url = owned_url, + .body = owned_body, + .expires_at = expires_at, + }; + self.next = (selected + 1) % capacity; + } + + fn lock(self: *Self) void { + while (!self.mutex.tryLock()) std.Thread.yield() catch {}; + } + + fn freeEntry(self: *Self, slot: *?Entry) void { + if (slot.*) |entry| { + self.allocator.free(entry.url); + self.allocator.free(entry.body); + slot.* = null; + } + } + }; +} + +test "document cache returns fresh values and expires stale values" { + var cache = DocumentCache(2).init(std.testing.allocator); + defer cache.deinit(); + + try cache.put("https://client.example/metadata", "{\"name\":\"client\"}", 20); + const fresh = (try cache.get(std.testing.allocator, "https://client.example/metadata", 19)).?; + defer std.testing.allocator.free(fresh); + try std.testing.expectEqualStrings("{\"name\":\"client\"}", fresh); + try std.testing.expect((try cache.get(std.testing.allocator, "https://client.example/metadata", 20)) == null); +} + +test "document cache replaces matching entries and evicts at capacity" { + var cache = DocumentCache(2).init(std.testing.allocator); + defer cache.deinit(); + + try cache.put("https://client.example/a", "old", 20); + try cache.put("https://client.example/a", "new", 30); + const replaced = (try cache.get(std.testing.allocator, "https://client.example/a", 10)).?; + defer std.testing.allocator.free(replaced); + try std.testing.expectEqualStrings("new", replaced); + + try cache.put("https://client.example/b", "b", 30); + try cache.put("https://client.example/c", "c", 30); + try std.testing.expect((try cache.get(std.testing.allocator, "https://client.example/a", 10)) == null); +} diff --git a/src/root.zig b/src/root.zig index 6b67515..d4f8f4d 100644 --- a/src/root.zig +++ b/src/root.zig @@ -37,6 +37,7 @@ pub const internal = struct { pub const client_attestation = @import("internal/client_attestation.zig"); pub const email_tokens = @import("internal/email_tokens.zig"); pub const dpop = @import("internal/dpop.zig"); + pub const document_cache = @import("internal/document_cache.zig"); pub const handles = @import("internal/handles.zig"); pub const passkeys = @import("internal/passkeys.zig"); pub const permissioned_data = @import("internal/permissioned_data.zig"); @@ -81,6 +82,7 @@ test { internal.client_attestation, internal.email_tokens, internal.dpop, + internal.document_cache, internal.handles, internal.passkeys, internal.permissioned_data, -- 2.51.2