feat(audit): Batch 24 — one validate-and-coerce schema layer for settings (CC9)

zod field schemas in settingsSchema.ts replace setSettings' bespoke
per-key switch: parse → skip-on-fail → store, side effects (secret
encryption, launch-at-startup sync) kept in settings.ts. sanitizeOptions
extracted to settingsOptions.ts to break the import cycle; backup import
flows through the same schema. Field-by-field parity pinned in
test/settingsSchema.test.ts (15 tests); full suite unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 12:41:00 -04:00
parent 6abed2064d
commit 8f6b2a761b
7 changed files with 330 additions and 197 deletions
+115
View File
@@ -0,0 +1,115 @@
/**
* The one validate-AND-coerce layer for Settings writes (CC9). Every key that
* setSettings accepts is described here as a zod schema whose output is exactly
* what gets persisted — replacing the bespoke per-key switch. The schemas reuse
* validation.ts's domain predicates (`isValidSyncTime`, `isSafeOutputDir`,
* `isSafeFilenameTemplate`) as refinements, so the rules live in one place.
*
* Semantics are a faithful port of the old switch (unit-tested in
* test/settingsSchema.test.ts): invalid values fail parse and are SKIPPED by
* applySettings (the stored value stays), numbers coerce + clamp, strings trim
* where they trimmed before. Side effects (secret encryption, launch-at-startup
* registration) stay in settings.ts — this layer is pure.
*
* The jsonStore row guards (`isValid*` in validation.ts) intentionally stay
* type-guard predicates: they filter untrusted disk rows in a hot read path and
* need no coercion.
*/
import { z } from 'zod'
import {
ACCENT_COLORS,
COOKIE_BROWSERS,
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
YTDLP_UPDATE_CHANNELS,
isValidSyncTime,
type Settings
} from '@shared/ipc'
import { isSafeOutputDir, isSafeFilenameTemplate } from './validation'
import { sanitizeOptions } from './settingsOptions'
/** Coerce to a finite number, round, and clamp into [min, max] (maxConcurrent, aria2c). */
const clampedInt = (min: number, max: number) =>
z.coerce
.number()
.refine((n) => Number.isFinite(n))
.transform((n) => Math.min(max, Math.max(min, Math.round(n))))
/** Trimmed string that must satisfy the given predicate after trimming. */
const trimmedWhere = (ok: (v: string) => boolean) =>
z
.string()
.transform((v) => v.trim())
.refine(ok)
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
const RATE_LIMIT_RE = /^(\d+\.?\d*[KMGkmg]?B?)?$/
const bool = z.boolean()
/**
* One schema per Settings key; `applySettings` looks the key up, `safeParse`s
* the incoming value, and stores the parsed output — or skips the key entirely
* on failure. Typed as ZodType<unknown> per-field because outputs differ.
*/
export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings[K], unknown> } = {
theme: z.enum(['light', 'dark', 'system']),
accentColor: z.enum(ACCENT_COLORS),
defaultKind: z.enum(['video', 'audio']),
maxConcurrent: clampedInt(1, 5),
// aria2c caps -x/-s at 16; clamp like maxConcurrent rather than reject (L64).
aria2cConnections: clampedInt(1, 16),
// Reaches `schtasks /ST` when the daily sync is (re)registered (L51).
syncTime: z.string().refine(isValidSyncTime),
watchClipboard: bool,
useAria2c: bool,
restrictFilenames: bool,
useDownloadArchive: bool,
autoUpdateYtdlp: bool,
enableCustomCommands: bool,
notifyOnComplete: bool,
autoDownloadNew: bool,
hasCompletedOnboarding: bool,
minimizeToTray: bool,
isSidebarCollapsed: bool,
useHardwareAcceleration: bool,
launchAtStartup: bool,
// Same allowlist that guards the `--update-to` flag (audit F1).
ytdlpChannel: z.enum(YTDLP_UPDATE_CHANNELS),
// Set by the main-process auto-updater; a bogus value at worst skips one check.
ytdlpLastUpdateCheck: z.coerce.number().refine((n) => Number.isFinite(n) && n >= 0),
defaultTemplateId: z.union([z.null(), z.string()]),
cookieSource: z.enum(['none', 'browser', 'login']),
cookiesBrowser: z.enum(COOKIE_BROWSERS),
// Always store a fully-validated object; the renderer sends the whole group.
downloadOptions: z.unknown().transform((v) => sanitizeOptions(v)),
// An empty string is allowed — it clears the override and restores the default.
videoFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
audioFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
filenameTemplate: trimmedWhere(isSafeFilenameTemplate),
defaultVideoQuality: z.enum(VIDEO_QUALITY_OPTIONS),
defaultAudioQuality: z.enum(AUDIO_QUALITY_OPTIONS),
rateLimit: trimmedWhere((v) => RATE_LIMIT_RE.test(v)),
// Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
youtubePlayerClient: z.string().transform((v) => v.trim()),
// Credential-bearing fields — settings.ts encrypts these AFTER parsing.
proxy: z.string(),
youtubePoToken: z.string(),
// An access token has no spaces; trim before encrypting (like proxy creds).
updateToken: z.string().transform((v) => v.trim())
}
/**
* Parse one incoming settings value. Returns `{ ok: true, value }` with the
* coerced/clamped/trimmed value to store, or `{ ok: false }` when the write
* must be skipped (unknown key or failed validation).
*/
export function parseSettingsField<K extends keyof Settings>(
key: K,
value: unknown
): { ok: true; value: Settings[K] } | { ok: false } {
const schema = SETTINGS_FIELD_SCHEMAS[key] as z.ZodType<Settings[K], unknown> | undefined
if (!schema) return { ok: false }
const parsed = schema.safeParse(value)
return parsed.success ? { ok: true, value: parsed.data } : { ok: false }
}