From 8f6b2a761bc1409d21ab356a3d9fb4c14bd6b9b3 Mon Sep 17 00:00:00 2001 From: debont80 Date: Thu, 2 Jul 2026 12:41:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(audit):=20Batch=2024=20=E2=80=94=20one=20v?= =?UTF-8?q?alidate-and-coerce=20schema=20layer=20for=20settings=20(CC9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CODE-AUDIT.md | 16 ++- package-lock.json | 11 ++ package.json | 1 + src/main/settings.ts | 212 ++++-------------------------------- src/main/settingsOptions.ts | 62 +++++++++++ src/main/settingsSchema.ts | 115 +++++++++++++++++++ test/settingsSchema.test.ts | 110 +++++++++++++++++++ 7 files changed, 330 insertions(+), 197 deletions(-) create mode 100644 src/main/settingsOptions.ts create mode 100644 src/main/settingsSchema.ts create mode 100644 test/settingsSchema.test.ts diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index 92f8d0f..9018d45 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -2018,13 +2018,21 @@ split — but that style is unenforced and several "do the same thing two ways" renderer's `logError` (M29) failures to the same file sink — closing the "sink" half M29/R6 deferred here. `errorlog.ts` stays as user-facing download-failure data. The user-facing **toast** half ties to the global status surface (UI25/UX9) and lands there.* -- [ ] **CC9 — Four validation styles.** Type-guard predicates (`isValid*` in validation.ts), +- [x] **CC9 — Four validation styles.** Type-guard predicates (`isValid*` in validation.ts), coerce-with-fallback (`sanitizeOptions`, `templates.sanitize`), an imperative per-key `switch` (`setSettings`, M24), and `throw` (`assertHttpUrl`). **Standard:** one schema layer (e.g. `zod`) that both *validates and coerces*; `setSettings` runs values through the same schema instead of a bespoke switch. - *Deferred to 1.x (per the release gate — the CC consolidations are explicitly post-1.0): adopting a schema - layer (`zod`) is a new runtime dependency plus a rewrite of `validation.ts` + `setSettings` + backup import. - High-value but a sweeping change best done as its own reviewed PR, not unattended; standard recorded.* + *Fixed (Batch 24): `zod` adopted; every Settings key is a schema in + [settingsSchema.ts](src/main/settingsSchema.ts) whose parsed output is exactly what gets persisted — + numbers coerce+clamp, strings trim, enums allowlist, folders/templates run the shared validation.ts + refinements, `downloadOptions` sanitizes (via the extracted + [settingsOptions.ts](src/main/settingsOptions.ts)). `setSettings`'s 130-line switch is now a 15-line loop: + parse → skip-on-fail → store, with the two genuine side effects (secret encryption at rest, the + launch-at-startup registry sync) kept beside their module state. Backup import flows through the same + schema (it calls setSettings). Parity with the old switch is pinned field-by-field in + [test/settingsSchema.test.ts](test/settingsSchema.test.ts); the whole suite passes unchanged. The jsonStore + row guards deliberately stay type-guard predicates (hot read path filtering untrusted disk rows — no + coercion wanted), documented in the schema header.* - [ ] **CC10 — Mixed serialization/persistence.** `electron-store` (settings) **and** hand-rolled pretty-JSON files (history/errorlog/templates/sources/media-items, M1) for the same job, plus yt-dlp-mandated formats (Netscape cookies, plaintext archive) and base64 `enc:v1:` secrets. diff --git a/package-lock.json b/package-lock.json index 537f0be..0abb262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "aerofetch", "version": "0.5.0", + "license": "UNLICENSED", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@fluentui/react-components": "^9.74.1", @@ -15,6 +16,7 @@ "electron-store": "^11.0.2", "react": "^19.2.7", "react-dom": "^19.2.7", + "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { @@ -9412,6 +9414,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index abcd30d..2999fc5 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "electron-store": "^11.0.2", "react": "^19.2.7", "react-dom": "^19.2.7", + "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/src/main/settings.ts b/src/main/settings.ts index 3037b27..c175a8f 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,28 +1,10 @@ import { app, safeStorage } from 'electron' import Store from 'electron-store' -import { isSafeFilenameTemplate, isSafeOutputDir } from './validation' import { RENAMED_SETTINGS_KEYS } from './settingsMigration' +import { parseSettingsField } from './settingsSchema' +import { sanitizeOptions } from './settingsOptions' import { logger } from './logger' -import { - AUDIO_FORMATS, - VIDEO_CONTAINERS, - VIDEO_CODECS, - SPONSORBLOCK_CATEGORIES, - COOKIE_BROWSERS, - ACCENT_COLORS, - VIDEO_QUALITY_OPTIONS, - AUDIO_QUALITY_OPTIONS, - DEFAULT_DOWNLOAD_OPTIONS, - DEFAULT_SETTINGS, - isYtdlpUpdateChannel, - isValidSyncTime, - type Settings, - type DownloadOptions, - type SponsorBlockCategory, - type AudioFormat, - type VideoContainer, - type VideoCodecPref -} from '@shared/ipc' +import { ACCENT_COLORS, DEFAULT_SETTINGS, type Settings, type DownloadOptions } from '@shared/ipc' // The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared // contract — single-sourced so the renderer FALLBACK and the preview mock can't @@ -44,53 +26,9 @@ export function applyLaunchAtStartup(enabled: boolean): void { } } -// Coerce an untrusted partial into a valid DownloadOptions, falling back to the -// defaults for any missing/invalid field. Used both to migrate older settings -// files (which predate downloadOptions) and to validate renderer writes. -function sanitizeOptions(input: unknown): DownloadOptions { - const o = (input && typeof input === 'object' ? input : {}) as Partial - const d = DEFAULT_DOWNLOAD_OPTIONS - const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback) - const cats = Array.isArray(o.sponsorBlockCategories) - ? (o.sponsorBlockCategories.filter((c) => - (SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c) - ) as SponsorBlockCategory[]) - : d.sponsorBlockCategories - return { - audioFormat: (AUDIO_FORMATS as readonly string[]).includes(o.audioFormat as string) - ? (o.audioFormat as AudioFormat) - : d.audioFormat, - videoContainer: (VIDEO_CONTAINERS as readonly string[]).includes(o.videoContainer as string) - ? (o.videoContainer as VideoContainer) - : d.videoContainer, - preferredVideoCodec: (VIDEO_CODECS as readonly string[]).includes( - o.preferredVideoCodec as string - ) - ? (o.preferredVideoCodec as VideoCodecPref) - : d.preferredVideoCodec, - formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort, - embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles), - subtitleLanguages: - typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim() - ? o.subtitleLanguages.trim() - : d.subtitleLanguages, - autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles), - sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock), - sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove', - sponsorBlockCategories: cats, - embedChapters: bool(o.embedChapters, d.embedChapters), - splitChapters: bool(o.splitChapters, d.splitChapters), - embedMetadata: bool(o.embedMetadata, d.embedMetadata), - metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined, - metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined, - metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined, - embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail), - cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail), - writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson), - writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile), - writeDescription: bool(o.writeDescription, d.writeDescription) - } -} +// sanitizeOptions moved to settingsOptions.ts (CC9) so the field schemas can +// reuse it without an import cycle; re-imported above for the legacy-file +// migration below. // Constructed lazily — electron-store needs app paths. (`userData` resolves // pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.) @@ -268,131 +206,19 @@ function applySettings(s: Store, partial: Partial): void { for (const key of Object.keys(partial) as (keyof Settings)[]) { const value = partial[key] if (value === undefined) continue - switch (key) { - case 'theme': - if (value === 'light' || value === 'dark' || value === 'system') s.set('theme', value) - break - case 'accentColor': - if ((ACCENT_COLORS as readonly string[]).includes(value as string)) { - s.set('accentColor', value as Settings['accentColor']) - } - break - case 'defaultKind': - if (value === 'video' || value === 'audio') s.set('defaultKind', value) - break - case 'maxConcurrent': { - const n = Number(value) - if (Number.isFinite(n)) s.set('maxConcurrent', Math.min(5, Math.max(1, Math.round(n)))) - break - } - case 'aria2cConnections': { - // aria2c caps -x/-s at 16; clamp like maxConcurrent rather than reject (L64). - const n = Number(value) - if (Number.isFinite(n)) s.set('aria2cConnections', Math.min(16, Math.max(1, Math.round(n)))) - break - } - case 'syncTime': - // Reaches `schtasks /ST` when the daily sync is (re)registered — only a - // well-formed 24h HH:MM is ever stored (L51). - if (isValidSyncTime(value)) s.set('syncTime', value) - break - case 'watchClipboard': - case 'useAria2c': - case 'restrictFilenames': - case 'useDownloadArchive': - case 'autoUpdateYtdlp': - case 'enableCustomCommands': - case 'notifyOnComplete': - case 'autoDownloadNew': - case 'hasCompletedOnboarding': - case 'minimizeToTray': - case 'isSidebarCollapsed': - case 'useHardwareAcceleration': - if (typeof value === 'boolean') s.set(key, value) - break - case 'launchAtStartup': - if (typeof value === 'boolean') { - s.set('launchAtStartup', value) - applyLaunchAtStartup(value) - } - break - case 'ytdlpChannel': - // Same allowlist that guards the `--update-to` flag (audit F1). - if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value) - break - case 'ytdlpLastUpdateCheck': { - // Set by the main-process auto-updater; validated here since setSettings - // is the only writer. A bogus value at worst skips/forces one check. - const n = Number(value) - if (Number.isFinite(n) && n >= 0) s.set('ytdlpLastUpdateCheck', n) - break - } - case 'defaultTemplateId': - if (value === null || typeof value === 'string') s.set('defaultTemplateId', value) - break - case 'cookieSource': - if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value) - break - case 'cookiesBrowser': - if ((COOKIE_BROWSERS as readonly string[]).includes(value as string)) { - s.set(key, value as Settings['cookiesBrowser']) - } - break - case 'downloadOptions': - // Always store a fully-validated object; the renderer sends the whole - // group (it merges field changes locally before calling setSettings). - s.set('downloadOptions', sanitizeOptions(value)) - break - case 'videoFolder': - case 'audioFolder': - // An empty string is allowed — it clears the override and restores the - // Documents\Video / Documents\Audio default for that kind. - if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) { - s.set(key, value.trim()) - } - break - case 'filenameTemplate': - if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) { - s.set('filenameTemplate', value.trim()) - } - break - case 'defaultVideoQuality': - if ( - typeof value === 'string' && - (VIDEO_QUALITY_OPTIONS as readonly string[]).includes(value) - ) { - s.set('defaultVideoQuality', value) - } - break - case 'defaultAudioQuality': - if ( - typeof value === 'string' && - (AUDIO_QUALITY_OPTIONS as readonly string[]).includes(value) - ) { - s.set('defaultAudioQuality', value) - } - break - case 'rateLimit': - // yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G". - if (typeof value === 'string' && /^(\d+\.?\d*[KMGkmg]?B?)?$/.test(value.trim())) { - s.set('rateLimit', value.trim()) - } - break - case 'youtubePlayerClient': - // Power-user field; yt-dlp's client list evolves. Accept any trimmed string. - if (typeof value === 'string') s.set('youtubePlayerClient', value.trim()) - break - // Credential-bearing fields — encrypted at rest (see encryptSecret). proxy - // may embed user:pass@host; youtubePoToken is an access token. Both are - // stripped from backup exports (see SECRET_KEYS / backup.ts). - case 'proxy': - case 'youtubePoToken': - if (typeof value === 'string') s.set(key, encryptSecret(value)) - break - case 'updateToken': - // An access token has no spaces; trim before encrypting (like proxy creds). - if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim())) - break + // One schema layer validates AND coerces every field (CC9) — the parsed + // output is exactly what gets stored; a failed parse skips the key so the + // stored value stays. Side effects that don't belong in a pure schema stay + // here: credential fields are encrypted at rest (see encryptSecret; they're + // stripped from backup exports via SECRET_KEYS), and launchAtStartup syncs + // the OS run-at-sign-in entry. + const parsed = parseSettingsField(key, value) + if (!parsed.ok) continue + if (SECRET_KEYS.includes(key as (typeof SECRET_KEYS)[number])) { + s.set(key, encryptSecret(parsed.value as string) as Settings[typeof key]) + } else { + s.set(key, parsed.value) } + if (key === 'launchAtStartup') applyLaunchAtStartup(parsed.value as boolean) } } diff --git a/src/main/settingsOptions.ts b/src/main/settingsOptions.ts new file mode 100644 index 0000000..d471c99 --- /dev/null +++ b/src/main/settingsOptions.ts @@ -0,0 +1,62 @@ +import { + AUDIO_FORMATS, + VIDEO_CONTAINERS, + VIDEO_CODECS, + SPONSORBLOCK_CATEGORIES, + DEFAULT_DOWNLOAD_OPTIONS, + type DownloadOptions, + type SponsorBlockCategory, + type AudioFormat, + type VideoContainer, + type VideoCodecPref +} from '@shared/ipc' + +// Coerce an untrusted partial into a valid DownloadOptions, falling back to the +// defaults for any missing/invalid field. Used both to migrate older settings +// files (which predate downloadOptions) and to validate renderer writes. Lives +// in its own module (not settings.ts) so the settings field schemas (CC9) can +// reuse it without an import cycle. +export function sanitizeOptions(input: unknown): DownloadOptions { + const o = (input && typeof input === 'object' ? input : {}) as Partial + const d = DEFAULT_DOWNLOAD_OPTIONS + const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback) + const cats = Array.isArray(o.sponsorBlockCategories) + ? (o.sponsorBlockCategories.filter((c) => + (SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c) + ) as SponsorBlockCategory[]) + : d.sponsorBlockCategories + return { + audioFormat: (AUDIO_FORMATS as readonly string[]).includes(o.audioFormat as string) + ? (o.audioFormat as AudioFormat) + : d.audioFormat, + videoContainer: (VIDEO_CONTAINERS as readonly string[]).includes(o.videoContainer as string) + ? (o.videoContainer as VideoContainer) + : d.videoContainer, + preferredVideoCodec: (VIDEO_CODECS as readonly string[]).includes( + o.preferredVideoCodec as string + ) + ? (o.preferredVideoCodec as VideoCodecPref) + : d.preferredVideoCodec, + formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort, + embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles), + subtitleLanguages: + typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim() + ? o.subtitleLanguages.trim() + : d.subtitleLanguages, + autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles), + sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock), + sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove', + sponsorBlockCategories: cats, + embedChapters: bool(o.embedChapters, d.embedChapters), + splitChapters: bool(o.splitChapters, d.splitChapters), + embedMetadata: bool(o.embedMetadata, d.embedMetadata), + metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined, + metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined, + metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined, + embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail), + cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail), + writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson), + writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile), + writeDescription: bool(o.writeDescription, d.writeDescription) + } +} diff --git a/src/main/settingsSchema.ts b/src/main/settingsSchema.ts new file mode 100644 index 0000000..a190877 --- /dev/null +++ b/src/main/settingsSchema.ts @@ -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 per-field because outputs differ. + */ +export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType } = { + 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( + key: K, + value: unknown +): { ok: true; value: Settings[K] } | { ok: false } { + const schema = SETTINGS_FIELD_SCHEMAS[key] as z.ZodType | undefined + if (!schema) return { ok: false } + const parsed = schema.safeParse(value) + return parsed.success ? { ok: true, value: parsed.data } : { ok: false } +} diff --git a/test/settingsSchema.test.ts b/test/settingsSchema.test.ts new file mode 100644 index 0000000..7847954 --- /dev/null +++ b/test/settingsSchema.test.ts @@ -0,0 +1,110 @@ +/** + * CC9: the zod field schemas must be a faithful port of the old setSettings + * switch — same accepts, same rejects, same coercions (clamp/trim/sanitize). + */ +import { describe, it, expect } from 'vitest' +import { parseSettingsField } from '../src/main/settingsSchema' +import { DEFAULT_DOWNLOAD_OPTIONS } from '@shared/ipc' + +const ok = (v: T): { ok: true; value: T } => ({ ok: true, value: v }) +const fail = { ok: false } + +describe('parseSettingsField (CC9 schema parity)', () => { + it('theme: enum only', () => { + expect(parseSettingsField('theme', 'dark')).toEqual(ok('dark')) + expect(parseSettingsField('theme', 'blue')).toEqual(fail) + expect(parseSettingsField('theme', 1)).toEqual(fail) + }) + + it('maxConcurrent: coerces, rounds, clamps to [1,5]', () => { + expect(parseSettingsField('maxConcurrent', 3)).toEqual(ok(3)) + expect(parseSettingsField('maxConcurrent', '4')).toEqual(ok(4)) + expect(parseSettingsField('maxConcurrent', 99)).toEqual(ok(5)) + expect(parseSettingsField('maxConcurrent', -2)).toEqual(ok(1)) + expect(parseSettingsField('maxConcurrent', 2.6)).toEqual(ok(3)) + expect(parseSettingsField('maxConcurrent', 'abc')).toEqual(fail) + }) + + it('aria2cConnections: clamps to [1,16] (L64)', () => { + expect(parseSettingsField('aria2cConnections', 64)).toEqual(ok(16)) + expect(parseSettingsField('aria2cConnections', 0)).toEqual(ok(1)) + }) + + it('syncTime: 24h HH:MM only (L51)', () => { + expect(parseSettingsField('syncTime', '09:30')).toEqual(ok('09:30')) + expect(parseSettingsField('syncTime', '9:30')).toEqual(fail) + expect(parseSettingsField('syncTime', '24:00')).toEqual(fail) + }) + + it('booleans: strict', () => { + expect(parseSettingsField('useAria2c', true)).toEqual(ok(true)) + expect(parseSettingsField('useAria2c', 'true')).toEqual(fail) + expect(parseSettingsField('enableCustomCommands', false)).toEqual(ok(false)) + }) + + it('ytdlpChannel: allowlist (F1)', () => { + expect(parseSettingsField('ytdlpChannel', 'nightly')).toEqual(ok('nightly')) + expect(parseSettingsField('ytdlpChannel', 'master')).toEqual(fail) + }) + + it('ytdlpLastUpdateCheck: non-negative finite number, coerced', () => { + expect(parseSettingsField('ytdlpLastUpdateCheck', 123)).toEqual(ok(123)) + expect(parseSettingsField('ytdlpLastUpdateCheck', -1)).toEqual(fail) + expect(parseSettingsField('ytdlpLastUpdateCheck', 'NaN')).toEqual(fail) + }) + + it('defaultTemplateId: string or null', () => { + expect(parseSettingsField('defaultTemplateId', null)).toEqual(ok(null)) + expect(parseSettingsField('defaultTemplateId', 't1')).toEqual(ok('t1')) + expect(parseSettingsField('defaultTemplateId', 7)).toEqual(fail) + }) + + it('downloadOptions: always sanitizes to a full valid object', () => { + const r = parseSettingsField('downloadOptions', { embedSubtitles: true, audioFormat: 'nope' }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.value.embedSubtitles).toBe(true) + expect(r.value.audioFormat).toBe(DEFAULT_DOWNLOAD_OPTIONS.audioFormat) + expect(Object.keys(r.value).length).toBeGreaterThanOrEqual( + Object.keys(DEFAULT_DOWNLOAD_OPTIONS).length - 3 // optional metadata fields may be undefined + ) + } + }) + + it('folders: trims; empty clears; relative paths rejected (isSafeOutputDir)', () => { + expect(parseSettingsField('videoFolder', ' D:\\Media ')).toEqual(ok('D:\\Media')) + expect(parseSettingsField('videoFolder', '')).toEqual(ok('')) + expect(parseSettingsField('audioFolder', 'Downloads')).toEqual(fail) + expect(parseSettingsField('audioFolder', '..\\elsewhere')).toEqual(fail) + }) + + it('filenameTemplate: trimmed and safety-checked', () => { + expect(parseSettingsField('filenameTemplate', ' %(title)s.%(ext)s ')).toEqual( + ok('%(title)s.%(ext)s') + ) + expect(parseSettingsField('filenameTemplate', '../%(title)s.%(ext)s')).toEqual(fail) + }) + + it('rateLimit: yt-dlp format or empty', () => { + expect(parseSettingsField('rateLimit', '2.5M')).toEqual(ok('2.5M')) + expect(parseSettingsField('rateLimit', ' 500K ')).toEqual(ok('500K')) + expect(parseSettingsField('rateLimit', '')).toEqual(ok('')) + expect(parseSettingsField('rateLimit', 'fast')).toEqual(fail) + }) + + it('quality presets: allowlisted', () => { + expect(parseSettingsField('defaultVideoQuality', '720p')).toEqual(ok('720p')) + expect(parseSettingsField('defaultVideoQuality', '4K')).toEqual(fail) + expect(parseSettingsField('defaultAudioQuality', 'Best')).toEqual(ok('Best')) + }) + + it('secrets: plain strings pass through (encryption happens in settings.ts)', () => { + expect(parseSettingsField('proxy', 'socks5://u:p@h:1')).toEqual(ok('socks5://u:p@h:1')) + expect(parseSettingsField('updateToken', ' tok ')).toEqual(ok('tok')) + expect(parseSettingsField('proxy', 42)).toEqual(fail) + }) + + it('unknown keys are skipped', () => { + expect(parseSettingsField('nope' as never, true)).toEqual(fail) + }) +})