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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user