From 379048f1912fc9f6992ccbcf37e5b5f4c5ee905f Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 3 May 2026 15:29:28 +0200 Subject: [PATCH] harden: defensive parsing for config + queue, normalize stale downloading - loadConfig now checks isPlainObject(parsed) before spreading over defaults. Non-object JSON (array, primitive, null) is logged and the app falls back to defaults instead of silently polluting the config with array indices or dropping values. - loadQueue runs every entry through sanitizeQueueItem which validates the status enum, clamps progress to [0, 100], validates customClip and mergeGroup shapes (with sanitizeCustomClip / sanitizeMergeGroup helpers), and demotes stale status="downloading" entries to "pending" with progress=0 on cold start. The previous filter only checked typeof id/url/status === "string" and let through whatever shape customClip / mergeGroup happened to have. - The stale-downloading normalisation fixes a real user trap: after a hard kill mid-download, the queue persisted status="downloading", but no download was running on next launch and start-download only resumed paused items, leaving "downloading" entries stuck. - Bonus: CustomClip and MergeGroupItem imports now have call sites (previously unused-import warnings). docs/IMPROVEMENT_LOG.md gains a Cycle 2 dated section. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/IMPROVEMENT_LOG.md | 31 +++++++++ src/main.ts | 139 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 162 insertions(+), 8 deletions(-) diff --git a/docs/IMPROVEMENT_LOG.md b/docs/IMPROVEMENT_LOG.md index fdc76b2..47160e3 100644 --- a/docs/IMPROVEMENT_LOG.md +++ b/docs/IMPROVEMENT_LOG.md @@ -2,6 +2,37 @@ Dated entries from improvement cycles. Newest at top. +## 2026-05-03 — Cycle 2: release pipeline + defensive parsing + +Three independent improvements landed this cycle. + +### 1. `scripts/release_gitea.mjs` skips rebuild when artifacts exist (release pipeline) + +- **File**: `scripts/release_gitea.mjs`. +- **Problem**: The script unconditionally ran `npm run dist:win` (full test suite + electron-builder) even when the version's artifacts were already on disk under `release/`. When `npm run test:e2e` was broken (cycle 1 follow-up), the release path was unusable — the previous cycle had to bypass the script with direct API uploads via PowerShell. Every future agent would hit the same wall. +- **Fix**: New `--skip-build` flag. The script now also auto-detects whether all 3 required artifacts (`Setup-.exe`, `Setup-.exe.blockmap`, `latest.yml`) exist for the requested version and skips `dist:win` accordingly. The auto-skip is the safe default — explicit `--skip-build` documents intent. Help text updated to describe the new flag and the auto-skip behaviour. + +### 2. `playwright` in `devDependencies` + simplified test scripts (release pipeline) + +- **Files**: `package.json` (+ `package-lock.json`). +- **Problem**: `npm exec --yes --package=playwright -- node scripts/smoke-test*.js` failed with `MODULE_NOT_FOUND` in environments where `npm exec` couldn't resolve playwright on the fly (clean caches, locked CI runners). Cycle 1 worked around it with `npm install --no-save playwright`. Result: the documented test path was unreliable. +- **Fix**: `playwright ^1.59.1` added to `devDependencies`. `test:e2e`, `test:e2e:guide`, `test:e2e:full` now invoke `node scripts/smoke-test*.js` directly — `require('playwright')` resolves locally. No browser binary install needed because the smoke tests drive Electron via `_electron`, not a browser. + +### 3. Defensive parsing in `loadConfig` and `loadQueue` (server-side correctness) + +- **File**: `src/main.ts` — new `isPlainObject` / `isValidQueueStatus` / `sanitizeCustomClip` / `sanitizeMergeGroup` / `sanitizeQueueItem` helpers; rewritten `loadConfig` and `loadQueue`. +- **Problem**: `loadConfig` blindly spread `JSON.parse(data)` over the defaults. If the config file ever held a non-object (corrupt, manually edited to an array, partial write before Cycle 1's fsync landed), the spread either dropped values silently (primitives) or polluted the config object (arrays became numeric keys). `loadQueue` only validated `id`, `url`, `status` are strings — it accepted `customClip` / `mergeGroup` of any shape, never validated `progress` was a finite number, and notably never normalized stale `status: 'downloading'` items. After a hard kill mid-download, those items came back marked as still downloading with no actual download running, and `start-download` only resurrected `paused` items, leaving them stuck. +- **Fix**: `loadConfig` checks `isPlainObject(parsed)` before spread; non-objects are logged and ignored, defaults used. `loadQueue` runs every entry through `sanitizeQueueItem` which validates the `status` enum, normalizes `progress` to `[0, 100]`, validates and normalizes `customClip` / `mergeGroup` shapes, and demotes stale `status: 'downloading'` to `pending` with `progress = 0` so the user can actually resume the queue. Invalid items are dropped with a count logged. As a bonus, the previously-unused `CustomClip` and `MergeGroupItem` type imports now have call sites. + +### Regression + +- `npm run build` — clean (TypeScript strict, 0 errors). +- `npm run test:e2e:update-logic` — passed. +- `npm run test:e2e` — passed via the new direct script path (no `npm exec` workaround), `issues: []`. +- `npm run test:e2e:guide` — passed. +- `npm run test:merge-split` — passed. +- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check). + ## 2026-05-03 — Cycle 1: stability & UX polish Three independent improvements landed this cycle. diff --git a/src/main.ts b/src/main.ts index a56a7b1..fce526e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -256,11 +256,20 @@ function normalizeConfigTemplates(input: Config): Config { }; } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function loadConfig(): Config { try { if (fs.existsSync(CONFIG_FILE)) { const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); - return normalizeConfigTemplates({ ...defaultConfig, ...JSON.parse(data) }); + const parsed = JSON.parse(data); + if (!isPlainObject(parsed)) { + console.error('Config file is not a JSON object — using defaults'); + return normalizeConfigTemplates(defaultConfig); + } + return normalizeConfigTemplates({ ...defaultConfig, ...parsed }); } } catch (e) { console.error('Error loading config:', e); @@ -303,6 +312,111 @@ function saveConfig(config: Config): void { // ========================================== // QUEUE MANAGEMENT // ========================================== +const VALID_QUEUE_STATUSES: ReadonlyArray = ['pending', 'downloading', 'paused', 'completed', 'error']; +const VALID_MERGE_PHASES: ReadonlyArray = ['downloading', 'merging', 'splitting', 'cleanup', 'done']; + +function isValidQueueStatus(status: unknown): status is QueueItem['status'] { + return typeof status === 'string' && (VALID_QUEUE_STATUSES as readonly string[]).includes(status); +} + +function sanitizeMergeGroup(raw: unknown): MergeGroup | undefined { + if (!isPlainObject(raw)) return undefined; + if (!Array.isArray(raw.items) || raw.items.length < 2) return undefined; + + const items: MergeGroupItem[] = []; + for (const mi of raw.items) { + if (!isPlainObject(mi)) continue; + if (typeof mi.url !== 'string' || typeof mi.title !== 'string' + || typeof mi.date !== 'string' || typeof mi.streamer !== 'string' + || typeof mi.duration_str !== 'string') continue; + items.push({ url: mi.url, title: mi.title, date: mi.date, streamer: mi.streamer, duration_str: mi.duration_str }); + } + if (items.length < 2) return undefined; + + const phase: MergeGroup['mergePhase'] = (VALID_MERGE_PHASES as readonly string[]).includes(String(raw.mergePhase)) + ? raw.mergePhase as MergeGroup['mergePhase'] + : 'downloading'; + + const downloadedFiles: Record = {}; + if (isPlainObject(raw.downloadedFiles)) { + for (const [k, v] of Object.entries(raw.downloadedFiles)) { + const idx = Number(k); + if (Number.isFinite(idx) && typeof v === 'string') downloadedFiles[idx] = v; + } + } + + return { + items, + mergePhase: phase, + currentItemIndex: typeof raw.currentItemIndex === 'number' && Number.isFinite(raw.currentItemIndex) ? raw.currentItemIndex : 0, + downloadedFiles, + mergedFile: typeof raw.mergedFile === 'string' ? raw.mergedFile : undefined, + splitFiles: Array.isArray(raw.splitFiles) ? raw.splitFiles.filter((f): f is string => typeof f === 'string') : undefined, + totalDurationSec: typeof raw.totalDurationSec === 'number' && Number.isFinite(raw.totalDurationSec) ? raw.totalDurationSec : undefined + }; +} + +function sanitizeCustomClip(raw: unknown): CustomClip | undefined { + if (!isPlainObject(raw)) return undefined; + const startSec = Number(raw.startSec); + const durationSec = Number(raw.durationSec); + const startPart = Number(raw.startPart); + if (!Number.isFinite(startSec) || !Number.isFinite(durationSec) || durationSec <= 0 || !Number.isFinite(startPart)) return undefined; + + const filenameFormat = raw.filenameFormat; + if (filenameFormat !== 'simple' && filenameFormat !== 'timestamp' && filenameFormat !== 'template') return undefined; + + return { + startSec: Math.max(0, startSec), + durationSec: Math.max(1, durationSec), + startPart: Math.max(1, Math.floor(startPart)), + filenameFormat, + filenameTemplate: typeof raw.filenameTemplate === 'string' ? raw.filenameTemplate : undefined + }; +} + +function sanitizeQueueItem(raw: unknown): QueueItem | null { + if (!isPlainObject(raw)) return null; + if (typeof raw.id !== 'string' || !raw.id) return null; + if (typeof raw.url !== 'string' || !raw.url) return null; + if (!isValidQueueStatus(raw.status)) return null; + + // 'downloading' on cold start is stale — no download is actually running + // and the user expects to resume from start, so map it back to 'pending' + const isStaleDownloading = raw.status === 'downloading'; + const finalStatus: QueueItem['status'] = isStaleDownloading ? 'pending' : raw.status; + + const progressNum = Number(raw.progress); + const safeProgress = Number.isFinite(progressNum) ? Math.max(0, Math.min(100, progressNum)) : 0; + + const item: QueueItem = { + id: raw.id, + url: raw.url, + title: typeof raw.title === 'string' ? raw.title : '', + date: typeof raw.date === 'string' ? raw.date : '', + streamer: typeof raw.streamer === 'string' ? raw.streamer : '', + duration_str: typeof raw.duration_str === 'string' ? raw.duration_str : '0s', + status: finalStatus, + progress: isStaleDownloading ? 0 : safeProgress + }; + + if (typeof raw.currentPart === 'number' && Number.isFinite(raw.currentPart)) item.currentPart = raw.currentPart; + if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts; + if (typeof raw.speed === 'string') item.speed = raw.speed; + if (typeof raw.eta === 'string') item.eta = raw.eta; + if (typeof raw.last_error === 'string') item.last_error = raw.last_error; + if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes; + if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; + + const customClip = sanitizeCustomClip(raw.customClip); + if (customClip) item.customClip = customClip; + + const mergeGroup = sanitizeMergeGroup(raw.mergeGroup); + if (mergeGroup) item.mergeGroup = mergeGroup; + + return item; +} + function loadQueue(): QueueItem[] { if (config.persist_queue_on_restart === false) { return []; @@ -312,16 +426,25 @@ function loadQueue(): QueueItem[] { if (fs.existsSync(QUEUE_FILE)) { const data = fs.readFileSync(QUEUE_FILE, 'utf-8'); const parsed = JSON.parse(data); - if (!Array.isArray(parsed)) return []; - return parsed.filter((item: any) => - item && typeof item.id === 'string' && - typeof item.url === 'string' && - typeof item.status === 'string' - ); + if (!Array.isArray(parsed)) { + console.error('Queue file is not a JSON array — ignoring'); + return []; + } + + const items: QueueItem[] = []; + let droppedCount = 0; + for (const raw of parsed) { + const sanitized = sanitizeQueueItem(raw); + if (sanitized) items.push(sanitized); + else droppedCount++; + } + if (droppedCount > 0) { + console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`); + } + return items; } } catch (e) { console.error('Error loading queue:', e); - console.error('queue-load-error', { error: String(e) }); } return []; }