diff --git a/src/main/telemetry/validator-warn-cache.test.ts b/src/main/telemetry/validator-warn-cache.test.ts new file mode 100644 index 000000000..2bc1cbcd7 --- /dev/null +++ b/src/main/telemetry/validator-warn-cache.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _getValidatorWarnCacheSizeForTests, + _resetValidatorWarnCacheForTests, + validate +} from './validator' + +describe('telemetry validator warn cache', () => { + beforeEach(() => { + _resetValidatorWarnCacheForTests() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('bounds warn rate-limit entries for unique invalid event names', () => { + for (let i = 0; i < 300; i++) { + validate(`not_a_real_event_${i}` as never, {}) + } + + expect(_getValidatorWarnCacheSizeForTests()).toBeLessThanOrEqual(256) + }) + + it('prunes expired warn rate-limit entries', () => { + vi.spyOn(Date, 'now').mockReturnValue(0) + validate('not_a_real_event' as never, {}) + + expect(_getValidatorWarnCacheSizeForTests()).toBe(1) + + vi.spyOn(Date, 'now').mockReturnValue(60_000) + validate('another_fake_event' as never, {}) + + expect(_getValidatorWarnCacheSizeForTests()).toBe(1) + }) +}) diff --git a/src/main/telemetry/validator.ts b/src/main/telemetry/validator.ts index 253af32e9..7d7785897 100644 --- a/src/main/telemetry/validator.ts +++ b/src/main/telemetry/validator.ts @@ -34,15 +34,36 @@ export type ValidationResult = | { ok: false; reason: string } const WARN_WINDOW_MS = 60_000 +const WARN_CACHE_MAX_ENTRIES = 256 const lastWarnAt = new Map() +function pruneWarnCache(now: number): void { + for (const [key, at] of lastWarnAt) { + if (now - at >= WARN_WINDOW_MS) { + lastWarnAt.delete(key) + } + } + while (lastWarnAt.size > WARN_CACHE_MAX_ENTRIES) { + const oldest = lastWarnAt.keys().next() + if (oldest.done) { + break + } + lastWarnAt.delete(oldest.value) + } +} + function warnRateLimited(key: string, message: string): void { const now = Date.now() - const prev = lastWarnAt.get(key) ?? 0 - if (now - prev < WARN_WINDOW_MS) { + pruneWarnCache(now) + const prev = lastWarnAt.get(key) + if (prev !== undefined && now - prev < WARN_WINDOW_MS) { return } + // Why: renderer-originated event names are untrusted; keep the rate-limit + // table bounded even if a bad caller sends unique invalid names forever. + lastWarnAt.delete(key) lastWarnAt.set(key, now) + pruneWarnCache(now) console.warn(`[telemetry] ${message}`) } @@ -76,6 +97,10 @@ export function _resetValidatorWarnCacheForTests(): void { lastWarnAt.clear() } +export function _getValidatorWarnCacheSizeForTests(): number { + return lastWarnAt.size +} + // Re-exported so `client.ts` can re-validate the merged outgoing payload // without reaching into `src/shared/telemetry-events.ts` directly. Keeps the // validator as the single surface the client depends on.