diff --git a/lib/__tests__/telemetry-state-read-error.test.ts b/lib/__tests__/telemetry-state-read-error.test.ts index 8c11b79d0..aa630bdfd 100644 --- a/lib/__tests__/telemetry-state-read-error.test.ts +++ b/lib/__tests__/telemetry-state-read-error.test.ts @@ -40,6 +40,16 @@ describe('telemetry state that cannot be read', () => { expect((await effectiveConsent()).consent).toBe('on'); }); + it('refuses to write over it', async () => { + const { updateState } = await import('@/lib/telemetry/state'); + mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'state.json'); + writeFileSync(file, '{"consent":"on","endp'); + + await expect(updateState((state) => { state.nextScheduledAt = 'x'; })).rejects.toThrow('could not be read'); + expect(readFileSync(file, 'utf8')).toBe('{"consent":"on","endp'); + }); + it('still writes the default state on a fresh install', async () => { const { loadState } = await import('@/lib/telemetry/state'); expect((await loadState()).consent).toBe('off'); diff --git a/lib/telemetry/state.ts b/lib/telemetry/state.ts index cde8620b6..9a82f2c2a 100644 --- a/lib/telemetry/state.ts +++ b/lib/telemetry/state.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { logger } from '@/lib/logger'; import { getStore, type CollectionOptions } from '@/lib/shared-store'; -import { isStoreUnavailable } from '@/lib/stored-data-errors'; +import { isStoreUnavailable, StoredDataUnreadableError } from '@/lib/stored-data-errors'; import type { TelemetryStateFile, ConsentState } from './types'; import { DEFAULT_ENDPOINT } from './types'; import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; @@ -143,7 +143,8 @@ export async function saveState(state: TelemetryStateFile): Promise { /** * Change some fields of the stored state without undoing a write that * landed meanwhile (another replica scheduling, the admin changing consent). - * `change` may run more than once. Returns the state written. + * `change` may run more than once. Returns the state written. A state file + * that doesn't parse throws StoredDataUnreadableError and is left as it is. */ export async function updateState(change: (state: TelemetryStateFile) => void): Promise { let next: TelemetryStateFile = { ...DEFAULTS }; @@ -151,8 +152,10 @@ export async function updateState(change: (state: TelemetryStateFile) => void): let state: TelemetryStateFile; try { state = current ? parse(current) : { ...DEFAULTS }; - } catch { - state = { ...DEFAULTS }; // a corrupt file: this write replaces it + } catch (err) { + // Unreadable is not missing, here as on reads: writing the defaults + // with one change would throw away the admin's consent and endpoint. + throw new StoredDataUnreadableError('telemetry state', { cause: err }); } change(state); next = state;