|
|
@@ -27,6 +27,16 @@ use App\Domain\Time\Clock;
|
|
|
*/
|
|
|
class BlocklistCache
|
|
|
{
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F71: max distinct policies kept in the cache map.
|
|
|
+ * Admins can create unbounded numbers of policies; without this
|
|
|
+ * cap the cache grew monotonically over the worker's lifetime.
|
|
|
+ * 16 covers any realistic deployment (one policy per consumer
|
|
|
+ * tier is the typical pattern) while bounding worst-case memory
|
|
|
+ * to ~16 × the per-policy build cost.
|
|
|
+ */
|
|
|
+ public const MAX_ENTRIES = 16;
|
|
|
+
|
|
|
/**
|
|
|
* @var array<int, array{blocklist: Blocklist, expires_at: float, rendered: array<string, array{body: string, etag: string}>}>
|
|
|
*/
|
|
|
@@ -36,6 +46,7 @@ class BlocklistCache
|
|
|
private readonly BlocklistBuilder $builder,
|
|
|
private readonly Clock $clock,
|
|
|
private readonly int $ttlSeconds = 30,
|
|
|
+ private readonly int $maxEntries = self::MAX_ENTRIES,
|
|
|
) {
|
|
|
}
|
|
|
|
|
|
@@ -44,16 +55,20 @@ class BlocklistCache
|
|
|
$now = (float) $this->clock->now()->getTimestamp();
|
|
|
$entry = $this->cache[$policy->id] ?? null;
|
|
|
if ($entry !== null && $now < $entry['expires_at']) {
|
|
|
+ // SEC_REVIEW F71: touch the entry on read so the LRU
|
|
|
+ // eviction sees it as recently used.
|
|
|
+ $this->touch($policy->id);
|
|
|
+
|
|
|
return $entry['blocklist'];
|
|
|
}
|
|
|
|
|
|
$blocklist = $this->builder->build($policy);
|
|
|
if ($this->ttlSeconds > 0) {
|
|
|
- $this->cache[$policy->id] = [
|
|
|
+ $this->store($policy->id, [
|
|
|
'blocklist' => $blocklist,
|
|
|
'expires_at' => $now + $this->ttlSeconds,
|
|
|
'rendered' => [],
|
|
|
- ];
|
|
|
+ ]);
|
|
|
}
|
|
|
|
|
|
return $blocklist;
|
|
|
@@ -102,7 +117,10 @@ class BlocklistCache
|
|
|
}
|
|
|
|
|
|
if ($this->ttlSeconds > 0) {
|
|
|
- $this->cache[$policy->id] = $entry;
|
|
|
+ // SEC_REVIEW F71: write through `store()` so the LRU
|
|
|
+ // eviction marks this entry most-recently-used and drops
|
|
|
+ // the oldest entry past `maxEntries`.
|
|
|
+ $this->store($policy->id, $entry);
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
@@ -121,4 +139,64 @@ class BlocklistCache
|
|
|
{
|
|
|
$this->cache = [];
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Number of policies currently held in the cache. Test-only:
|
|
|
+ * lets a regression spec assert the LRU bound is enforced
|
|
|
+ * without reaching into the private array via reflection.
|
|
|
+ *
|
|
|
+ * @internal
|
|
|
+ */
|
|
|
+ public function entryCount(): int
|
|
|
+ {
|
|
|
+ return count($this->cache);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Test-only: the policy IDs in the cache, in LRU-eviction order
|
|
|
+ * (oldest first, most-recently-used last).
|
|
|
+ *
|
|
|
+ * @return list<int>
|
|
|
+ * @internal
|
|
|
+ */
|
|
|
+ public function cachedPolicyIds(): array
|
|
|
+ {
|
|
|
+ return array_keys($this->cache);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F71: write a cache entry and enforce the
|
|
|
+ * `maxEntries` LRU bound. PHP arrays preserve insertion order,
|
|
|
+ * so unsetting then assigning the key pushes it to the end of
|
|
|
+ * the iteration order — making `array_key_first()` the
|
|
|
+ * least-recently-used entry to evict.
|
|
|
+ *
|
|
|
+ * @param array{blocklist: Blocklist, expires_at: float, rendered: array<string, array{body: string, etag: string}>} $entry
|
|
|
+ */
|
|
|
+ private function store(int $policyId, array $entry): void
|
|
|
+ {
|
|
|
+ unset($this->cache[$policyId]);
|
|
|
+ $this->cache[$policyId] = $entry;
|
|
|
+ while (count($this->cache) > $this->maxEntries) {
|
|
|
+ $oldest = array_key_first($this->cache);
|
|
|
+ if ($oldest === null) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ unset($this->cache[$oldest]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Mark an existing entry as most-recently-used. No-op if the
|
|
|
+ * entry isn't in the cache.
|
|
|
+ */
|
|
|
+ private function touch(int $policyId): void
|
|
|
+ {
|
|
|
+ if (!array_key_exists($policyId, $this->cache)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ $entry = $this->cache[$policyId];
|
|
|
+ unset($this->cache[$policyId]);
|
|
|
+ $this->cache[$policyId] = $entry;
|
|
|
+ }
|
|
|
}
|