diff --git a/.gitea/workflows/windows-ci.yml b/.gitea/workflows/windows-ci.yml new file mode 100644 index 0000000..c8ede09 --- /dev/null +++ b/.gitea/workflows/windows-ci.yml @@ -0,0 +1,60 @@ +name: Windows CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: windows-latest + env: + CI: 'true' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.13.0' + cache: npm + - name: Clean install + run: npm ci + timeout-minutes: 10 + - name: Lint + run: npm run lint + timeout-minutes: 10 + - name: Lint configuration contract + run: npm run test:lint-config + timeout-minutes: 10 + - name: Security contracts + run: npm run security:check + timeout-minutes: 10 + - name: Security regression tests + run: npm run test:security + timeout-minutes: 10 + - name: CI contract + run: npm run test:ci-contract + timeout-minutes: 10 + - name: Unit tests + run: npm run test:unit + timeout-minutes: 10 + - name: Focused Electron smoke + run: npm run test:e2e:focused + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Package directory + run: npm run pack + timeout-minutes: 10 + - name: Packaged launch smoke + run: npm run test:packaged-launch + timeout-minutes: 10 + - name: Build installer + run: npm run dist:ci + timeout-minutes: 10 + - name: Installer smoke + run: npm run test:installer + timeout-minutes: 10 diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml new file mode 100644 index 0000000..c8ede09 --- /dev/null +++ b/.github/workflows/windows-ci.yml @@ -0,0 +1,60 @@ +name: Windows CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: windows-latest + env: + CI: 'true' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.13.0' + cache: npm + - name: Clean install + run: npm ci + timeout-minutes: 10 + - name: Lint + run: npm run lint + timeout-minutes: 10 + - name: Lint configuration contract + run: npm run test:lint-config + timeout-minutes: 10 + - name: Security contracts + run: npm run security:check + timeout-minutes: 10 + - name: Security regression tests + run: npm run test:security + timeout-minutes: 10 + - name: CI contract + run: npm run test:ci-contract + timeout-minutes: 10 + - name: Unit tests + run: npm run test:unit + timeout-minutes: 10 + - name: Focused Electron smoke + run: npm run test:e2e:focused + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Package directory + run: npm run pack + timeout-minutes: 10 + - name: Packaged launch smoke + run: npm run test:packaged-launch + timeout-minutes: 10 + - name: Build installer + run: npm run dist:ci + timeout-minutes: 10 + - name: Installer smoke + run: npm run test:installer + timeout-minutes: 10 diff --git a/eslint.config.mjs b/eslint.config.mjs index 4082710..9b6865c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,25 +1,81 @@ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import security from 'eslint-plugin-security'; +import globals from 'globals'; export default [ + { + ignores: ['dist/**', 'release/**', 'node_modules/**', 'tmp_*/**', 'docs/**'] + }, js.configs.recommended, ...tseslint.configs.recommended, security.configs.recommended, { files: ['src/**/*.ts'], rules: { - // Tune down noisy rules for existing codebase '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 'no-console': 'off', - 'security/detect-object-injection': 'off', // Too many false positives with Record types - 'security/detect-non-literal-fs-filename': 'off', // All paths come from controlled sources + 'security/detect-object-injection': 'off', + 'security/detect-non-literal-fs-filename': 'off', 'no-async-promise-executor': 'warn', - 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-empty': ['warn', { allowEmptyCatch: true }] } }, { - ignores: ['dist/**', 'release/**', 'node_modules/**', 'scripts/**', 'tmp_*/**'] + files: ['src/renderer.ts', 'src/renderer-*.ts'], + ignores: ['src/**/*.test.ts'], + languageOptions: { + sourceType: 'script', + globals: globals.browser + }, + rules: { + '@typescript-eslint/no-unused-vars': 'off', + 'prefer-const': 'off' + } + }, + { + files: ['scripts/**/*.js'], + languageOptions: { + sourceType: 'commonjs', + globals: globals.node + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + 'no-console': 'off', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'security/detect-object-injection': 'off', + 'security/detect-non-literal-fs-filename': 'off' + } + }, + { + files: ['scripts/**/*.mjs'], + languageOptions: { + sourceType: 'module', + globals: globals.node + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + 'no-console': 'off', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'security/detect-object-injection': 'off', + 'security/detect-non-literal-fs-filename': 'off' + } + }, + { + files: ['scripts/smoke-test*.js', 'scripts/capture-readme-screenshot.js', 'scripts/e2e-test-environment.js'], + languageOptions: { + globals: { + ...globals.node, + ...globals.browser + } + }, + rules: { + 'no-undef': 'off' + } } ]; diff --git a/package-lock.json b/package-lock.json index 500b278..f82a04d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "electron-builder": "^26.15.7", "eslint": "^10.4.0", "eslint-plugin-security": "^4.0.0", + "globals": "^16.4.0", "playwright": "^1.60.0", "typescript": "^5.3.0", "typescript-eslint": "^8.59.4", @@ -3372,6 +3373,19 @@ "node": ">=10.0" } }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", diff --git a/package.json b/package.json index 6a01442..4a7d173 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ }, "scripts": { "build": "tsc", + "lint": "eslint .", + "security:check": "node scripts/security-check.js && node scripts/smoke-test-public-release-config.js", "start": "npm run build && electron .", "dev": "node scripts/dev.mjs", "test:unit": "vitest run --passWithNoTests", @@ -24,11 +26,18 @@ "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", "test:capability-contract": "node scripts/smoke-test-file-capability-contract.js", "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", + "test:e2e:focused": "npm run test:e2e:isolation && npm run test:e2e:workspace-ui", + "test:security": "node --test scripts/security-check.test.js", + "test:lint-config": "node --test scripts/lint-config.test.mjs", + "test:ci-contract": "node scripts/smoke-test-ci-contract.js", + "test:packaged-launch": "node scripts/smoke-test-packaged-launch.js", + "test:installer": "node scripts/smoke-test-installer.js", "test:e2e:release": "npm run build && npm run test:unit && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", "dist:win": "npm run test:e2e:release && electron-builder --win", + "dist:ci": "electron-builder --win nsis", "test:merge-split": "node scripts/smoke-test-merge-split-logic.js" }, "dependencies": { @@ -44,6 +53,7 @@ "electron-builder": "^26.15.7", "eslint": "^10.4.0", "eslint-plugin-security": "^4.0.0", + "globals": "^16.4.0", "playwright": "^1.60.0", "typescript": "^5.3.0", "typescript-eslint": "^8.59.4", @@ -72,6 +82,7 @@ "win": { "target": "nsis", "icon": "build/icon.ico", + "signExecutable": false, "artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}" }, "nsis": { diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 07f89a5..3b414cf 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -59,7 +59,10 @@ function restartElectron() { function isElectronRestartTarget(fileName) { const baseName = fileName.replaceAll('\\', '/').split('/').at(-1) ?? ''; - return !/^renderer(?:[-.].+)?\.js$/.test(baseName); + if (baseName === 'renderer.js') return false; + if (!baseName.startsWith('renderer') || !baseName.endsWith('.js')) return true; + const suffix = baseName.slice(8); + return suffix.length <= 4 || (suffix[0] !== '-' && suffix[0] !== '.'); } function scheduleRestart(fileName) { diff --git a/scripts/e2e-test-environment.js b/scripts/e2e-test-environment.js index 18e6c54..2405025 100644 --- a/scripts/e2e-test-environment.js +++ b/scripts/e2e-test-environment.js @@ -6,9 +6,6 @@ const OFFLINE_PROXY = 'http://127.0.0.1:1'; function buildSafeConfig(downloadsDir, overrides = {}) { return { - client_id: '', - download_path: downloadsDir, - streamers: [], theme: 'twitch', download_mode: 'full', part_minutes: 120, @@ -22,30 +19,17 @@ function buildSafeConfig(downloadsDir, overrides = {}) { persist_queue_on_restart: false, metadata_cache_minutes: 10, parallel_downloads: 1, - auto_resume_queue_on_startup: false, downloaded_vod_ids: [], streamlink_quality: 'best', notify_on_each_completion: false, streamlink_disable_ads: true, - auto_record_streamers: [], auto_record_poll_seconds: 90, - download_chat_replay: false, - capture_live_chat: false, - discord_notify_live_start: false, - discord_notify_live_end: false, - discord_notify_vod_complete: false, - discord_notify_vod_auto_queued: false, - auto_cleanup_enabled: false, auto_cleanup_days: 30, auto_cleanup_target: 'live_only', auto_cleanup_action: 'archive', log_stream_events: false, - auto_vod_download_streamers: [], auto_vod_download_poll_minutes: 15, auto_vod_max_age_hours: 24, - auto_resume_live_recording: false, - auto_merge_resumed_parts: false, - delete_parts_after_merge: false, ...overrides, client_id: '', download_path: downloadsDir, diff --git a/scripts/lint-config.test.mjs b/scripts/lint-config.test.mjs new file mode 100644 index 0000000..b0de9af --- /dev/null +++ b/scripts/lint-config.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const eslint = new ESLint({ cwd: root, overrideConfigFile: path.join(root, 'eslint.config.mjs') }); + +async function messagesFor(source, filePath) { + const [result] = await eslint.lintText(source, { filePath: path.join(root, filePath) }); + return result.messages; +} + +test('accepts classic renderer declarations and browser globals', async () => { + const messages = await messagesFor('function openPanel() { return document.title; }\n', 'src/renderer-contract-fixture.ts'); + assert.deepEqual(messages, []); +}); + +test('keeps recommended renderer errors active', async () => { + const messages = await messagesFor('function openPanel() { debugger; return document.title; }\n', 'src/renderer-contract-fixture.ts'); + assert.ok(messages.some((message) => message.ruleId === 'no-debugger' && message.severity === 2)); +}); + +test('accepts CommonJS imports and rejects unused script bindings', async () => { + const messages = await messagesFor("const fs = require('node:fs');\nconst unused = fs;\n", 'scripts/contract-fixture.js'); + assert.ok(messages.some((message) => message.ruleId === 'no-unused-vars' && message.severity === 2)); + assert.ok(!messages.some((message) => message.ruleId === '@typescript-eslint/no-require-imports')); +}); diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index bed6968..8bf62bd 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -1,5 +1,7 @@ { "files": [ + ".gitea/workflows/windows-ci.yml", + ".github/workflows/windows-ci.yml", ".gitignore", "CHANGELOG.md", "LICENSE", @@ -12,13 +14,21 @@ "package-lock.json", "package.json", "scripts/e2e-test-environment.js", + "scripts/file-capability-contract.js", + "scripts/lint-config.test.mjs", "scripts/capture-readme-screenshot.js", "scripts/dev.mjs", "scripts/public-release-files.json", + "scripts/security-check.js", + "scripts/security-check.test.js", + "scripts/smoke-test-ci-contract.js", "scripts/smoke-test-cutter.js", "scripts/smoke-test-e2e-isolation-contract.js", + "scripts/smoke-test-file-capability-contract.js", "scripts/smoke-test-full.js", + "scripts/smoke-test-installer.js", "scripts/smoke-test-merge-split-logic.js", + "scripts/smoke-test-packaged-launch.js", "scripts/smoke-test-public-release-config.js", "scripts/smoke-test-settings-autosave.js", "scripts/smoke-test-template-guide.js", @@ -26,6 +36,33 @@ "scripts/smoke-test-workspace-ui.js", "scripts/smoke-test.js", "src/index.html", + "src/main/domain/app-state-store.test.ts", + "src/main/domain/app-state-store.ts", + "src/main/domain/chat-reader.test.ts", + "src/main/domain/chat-reader.ts", + "src/main/domain/config-export.test.ts", + "src/main/domain/config-export.ts", + "src/main/domain/cutter-project.test.ts", + "src/main/domain/cutter-project.ts", + "src/main/domain/download-policy.test.ts", + "src/main/domain/download-policy.ts", + "src/main/domain/file-capability.test.ts", + "src/main/domain/file-capability.ts", + "src/main/domain/managed-tools.test.ts", + "src/main/domain/managed-tools.ts", + "src/main/domain/persistence-commit.test.ts", + "src/main/domain/persistence-commit.ts", + "src/main/domain/privileged-ipc.test.ts", + "src/main/domain/privileged-ipc.ts", + "src/main/domain/renderer-queue-input.test.ts", + "src/main/domain/renderer-queue-input.ts", + "src/main/domain/secret-input.test.ts", + "src/main/domain/secret-input.ts", + "src/main/domain/secret-store.test.ts", + "src/main/domain/secret-store.ts", + "src/main/domain/tool-manifest.ts", + "src/main/domain/update-check-operation.test.ts", + "src/main/domain/update-check-operation.ts", "src/main/domain/archive-files-store.test.ts", "src/main/domain/archive-files-store.ts", "src/main/domain/chunk-index-store.test.ts", @@ -76,12 +113,18 @@ "src/main/infra/schema-v5.ts", "src/main/infra/secure-storage.test.ts", "src/main/infra/secure-storage.ts", + "src/main/queue/process-lifecycle.integration.test.ts", + "src/main/queue/process-registry.test.ts", + "src/main/queue/process-registry.ts", "src/main.ts", "src/main/dev-executable.test.ts", "src/main/dev-executable.ts", "src/main/dev-reload.test.ts", "src/main/dev-reload.ts", "src/preload.ts", + "src/renderer-accessibility.integration.test.ts", + "src/renderer-accessibility.test.ts", + "src/renderer-accessibility.ts", "src/renderer-archive.ts", "src/renderer-command-palette.ts", "src/renderer-cutter.ts", @@ -89,8 +132,10 @@ "src/renderer-locale-de.ts", "src/renderer-locale-en.ts", "src/renderer-profile.ts", + "src/renderer-production-path.integration.test.ts", "src/renderer-queue.ts", "src/renderer-settings.ts", + "src/renderer-settings-autosave.test.ts", "src/renderer-shared.ts", "src/renderer-stats.ts", "src/renderer-streamers.ts", diff --git a/scripts/security-check.js b/scripts/security-check.js new file mode 100644 index 0000000..f47bbaf --- /dev/null +++ b/scripts/security-check.js @@ -0,0 +1,121 @@ +const fs = require('fs'); +const path = require('path'); + +const textExtensions = new Set([ + '', '.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.nsh', '.ps1', '.ts', '.tsx', '.txt', '.yaml', '.yml' +]); + +const sensitivePatterns = [ + ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g], + ['aws-access-key', /\bAKIA[0-9A-Z]{16}\b/g], + ['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g], + ['discord-webhook', /https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/\d{8,}\/[A-Za-z0-9._-]{20,}/gi], + ['url-credentials', /https?:\/\/[^\s/@:]+:[^\s/@]+@/gi], + ['windows-user-path', /\b[A-Za-z]:\\Users\\[^\\/\s]+\\/g] +]; + +function lineNumberAt(source, index) { + return source.slice(0, index).split('\n').length; +} + +function scanText(relativePath, source) { + const findings = []; + for (const header of ['PRIVATE KEY', 'RSA PRIVATE KEY', 'EC PRIVATE KEY', 'DSA PRIVATE KEY', 'OPENSSH PRIVATE KEY', 'ENCRYPTED PRIVATE KEY']) { + const marker = `-----BEGIN ${header}-----`; + let index = source.indexOf(marker); + while (index >= 0) { + findings.push({ file: relativePath, line: lineNumberAt(source, index), rule: 'private-key' }); + index = source.indexOf(marker, index + marker.length); + } + } + for (const [rule, pattern] of sensitivePatterns) { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + findings.push({ file: relativePath, line: lineNumberAt(source, match.index ?? 0), rule }); + } + } + return findings; +} + +function isContainedPath(root, target) { + const relative = path.relative(root, target); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative); +} + +function inspectPublicFiles(root, entries) { + const findings = []; + const resolvedRoot = path.resolve(root); + for (const rawEntry of entries) { + const entry = String(rawEntry || '').replace(/\\/g, '/'); + const absolutePath = path.resolve(resolvedRoot, ...entry.split('/')); + if (!entry || path.isAbsolute(entry) || !isContainedPath(resolvedRoot, absolutePath)) { + findings.push({ file: entry || '', line: 0, rule: 'manifest-path' }); + continue; + } + if (!fs.existsSync(absolutePath)) { + findings.push({ file: entry, line: 0, rule: 'manifest-missing' }); + continue; + } + const stat = fs.lstatSync(absolutePath); + if (stat.isSymbolicLink()) { + findings.push({ file: entry, line: 0, rule: 'manifest-symlink' }); + continue; + } + if (!stat.isFile()) { + findings.push({ file: entry, line: 0, rule: 'manifest-file-type' }); + continue; + } + if (!textExtensions.has(path.extname(entry).toLowerCase())) continue; + findings.push(...scanText(entry, fs.readFileSync(absolutePath, 'utf8'))); + } + return findings; +} + +function inspectLockfile(lockfile) { + const findings = []; + if (!Number.isInteger(lockfile?.lockfileVersion) || lockfile.lockfileVersion < 3) { + findings.push({ file: 'package-lock.json', line: 0, rule: 'lockfile-version' }); + } + const rootPackage = lockfile?.packages?.[''] || {}; + for (const [name, specifier] of Object.entries({ + ...(rootPackage.dependencies || {}), + ...(rootPackage.devDependencies || {}) + })) { + if (/^(?:file:|git(?:\+|:)|https?:)/i.test(String(specifier))) { + findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-source', package: name }); + } + } + for (const [packagePath, metadata] of Object.entries(lockfile?.packages || {})) { + if (!packagePath || metadata?.link) continue; + const resolved = typeof metadata?.resolved === 'string' ? metadata.resolved : ''; + if (/^https:\/\/registry\.npmjs\.org\//i.test(resolved) && !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(metadata.integrity || ''))) { + findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-integrity', package: packagePath }); + } + if (resolved && !/^https:\/\/registry\.npmjs\.org\//i.test(resolved)) { + findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-source', package: packagePath }); + } + } + return findings; +} + +function run(root = process.cwd()) { + const manifestPath = path.join(root, 'scripts', 'public-release-files.json'); + const lockfilePath = path.join(root, 'package-lock.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const lockfile = JSON.parse(fs.readFileSync(lockfilePath, 'utf8')); + const entries = Array.isArray(manifest.files) ? manifest.files : []; + return [...inspectPublicFiles(root, entries), ...inspectLockfile(lockfile)]; +} + +if (require.main === module) { + try { + const failures = run(); + console.log(JSON.stringify({ failures }, null, 2)); + if (failures.length) process.exitCode = 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +module.exports = { inspectLockfile, inspectPublicFiles, run, scanText }; diff --git a/scripts/security-check.test.js b/scripts/security-check.test.js new file mode 100644 index 0000000..e4b6771 --- /dev/null +++ b/scripts/security-check.test.js @@ -0,0 +1,62 @@ +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); + +const securityCheck = require('./security-check'); + +test('detects credential material and private machine paths', () => { + const githubToken = ['gh', 'p_', 'a'.repeat(40)].join(''); + const privateKey = ['-----BEGIN ', 'PRIVATE KEY-----'].join(''); + const source = `${githubToken}\n${privateKey}\nC:\\Users\\real-user\\AppData`; + const findings = securityCheck.scanText('fixture.txt', source); + + assert.deepEqual(findings.map((finding) => finding.rule).sort(), [ + 'github-token', + 'private-key', + 'windows-user-path' + ]); +}); + +test('accepts public source with secret field names but no credential value', () => { + const findings = securityCheck.scanText('fixture.ts', "const client_secret = config.client_secret;\nconst token = '';\n"); + assert.deepEqual(findings, []); +}); + +test('rejects public manifest traversal and symbolic links', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-security-')); + const outside = path.join(path.dirname(root), `${path.basename(root)}-outside.txt`); + try { + fs.writeFileSync(path.join(root, 'safe.txt'), 'safe', 'utf8'); + fs.writeFileSync(outside, 'outside', 'utf8'); + const entries = ['safe.txt', '../outside.txt']; + const findings = securityCheck.inspectPublicFiles(root, entries); + assert.ok(findings.some((finding) => finding.rule === 'manifest-path')); + + if (process.platform === 'win32') { + fs.symlinkSync(outside, path.join(root, 'linked.txt'), 'file'); + const linkedFindings = securityCheck.inspectPublicFiles(root, ['linked.txt']); + assert.ok(linkedFindings.some((finding) => finding.rule === 'manifest-symlink')); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { force: true }); + } +}); + +test('requires registry dependency integrity in the lockfile', () => { + const lockfile = { + lockfileVersion: 3, + packages: { + '': { dependencies: { example: '^1.0.0' } }, + 'node_modules/example': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/example/-/example-1.0.0.tgz' + } + } + }; + + const findings = securityCheck.inspectLockfile(lockfile); + assert.ok(findings.some((finding) => finding.rule === 'dependency-integrity')); +}); diff --git a/scripts/smoke-test-ci-contract.js b/scripts/smoke-test-ci-contract.js new file mode 100644 index 0000000..ddd09c7 --- /dev/null +++ b/scripts/smoke-test-ci-contract.js @@ -0,0 +1,72 @@ +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const failures = []; + +function check(condition, message) { + if (!condition) failures.push(message); +} + +const requiredScripts = { + lint: 'eslint .', + 'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js', + 'test:security': 'node --test scripts/security-check.test.js', + 'test:lint-config': 'node --test scripts/lint-config.test.mjs', + 'test:ci-contract': 'node scripts/smoke-test-ci-contract.js', + 'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui', + 'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js', + 'test:installer': 'node scripts/smoke-test-installer.js', + 'dist:ci': 'electron-builder --win nsis' +}; + +for (const [name, command] of Object.entries(requiredScripts)) { + check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`); +} + +for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) { + const absolutePath = path.join(root, relativePath); + check(fs.existsSync(absolutePath), `${relativePath} is missing`); + if (!fs.existsSync(absolutePath)) continue; + + const source = fs.readFileSync(absolutePath, 'utf8'); + const requiredCommands = [ + 'npm ci', + 'npm run lint', + 'npm run test:lint-config', + 'npm run security:check', + 'npm run test:security', + 'npm run test:ci-contract', + 'npm run test:unit', + 'npm run test:e2e:focused', + 'npm run build', + 'npm run pack', + 'npm run test:packaged-launch', + 'npm run dist:ci', + 'npm run test:installer' + ]; + + check(/runs-on:\s*windows-latest/.test(source), `${relativePath} does not use a Windows runner`); + check(/node-version:\s*['"]?22\.13\.0['"]?/.test(source), `${relativePath} does not pin Node 22.13.0`); + for (const command of requiredCommands) { + check(source.includes(`run: ${command}`), `${relativePath} is missing ${command}`); + } + const runSteps = source.split(/\r?\n/).filter((line) => /^\s+run:\s+/.test(line)); + const timeoutSteps = source.split(/\r?\n/).filter((line) => /^\s+timeout-minutes:\s*10\s*$/.test(line)); + check(timeoutSteps.length >= runSteps.length, `${relativePath} does not cap every command at ten minutes`); + check(!/test:[^\s]*authenticated|TWITCH_CLIENT_SECRET|DISCORD_WEBHOOK/i.test(source), `${relativePath} includes authenticated integration inputs`); +} + +for (const relativePath of [ + 'scripts/security-check.js', + 'scripts/security-check.test.js', + 'scripts/lint-config.test.mjs', + 'scripts/smoke-test-packaged-launch.js', + 'scripts/smoke-test-installer.js' +]) { + check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`); +} + +console.log(JSON.stringify({ failures }, null, 2)); +if (failures.length) process.exitCode = 1; diff --git a/scripts/smoke-test-cutter.js b/scripts/smoke-test-cutter.js index 737de71..72579cd 100644 --- a/scripts/smoke-test-cutter.js +++ b/scripts/smoke-test-cutter.js @@ -175,7 +175,7 @@ async function run() { const staleCutterDirectories = ['media', 'waveform', 'preview'].map((kind) => path.join(os.tmpdir(), `tvm-editor-${kind}-2147483647-${Date.now()}-${Math.random().toString(36).slice(2)}`)); staleCutterDirectories.forEach((directory) => fs.mkdirSync(directory)); let realMaximumZoomState = null; - let replacementPromptState = null; + let replacementPromptState; let replacementPlaybackState = null; let app; const check = (condition, message) => { if (!condition) failures.push(message); }; @@ -431,7 +431,6 @@ async function run() { const tiles = [...strip.querySelectorAll('img:not(.cutter-thumbnail-sprite), .cutter-thumbnail-tile')]; const waveform = document.getElementById('cutterWaveform'); await Promise.all([...images, waveform].map((image) => image.decode())); - const scroll = document.getElementById('cutterTimelineScroll'); const timeline = document.getElementById('timeline'); const targetWidth = Math.min(32000, Math.ceil(timeline.getBoundingClientRect().width * window.devicePixelRatio)); const firstFrameRect = images[0].getBoundingClientRect(); diff --git a/scripts/smoke-test-full.js b/scripts/smoke-test-full.js index 3519b2c..b3951fd 100644 --- a/scripts/smoke-test-full.js +++ b/scripts/smoke-test-full.js @@ -107,7 +107,7 @@ async function run() { await win.waitForTimeout(2200); - const summary = await win.evaluate(async ({ mediaA, mediaB, tmpDir }) => { + const summary = await win.evaluate(async () => { const failures = []; const checks = {}; @@ -357,10 +357,6 @@ async function run() { } return { checks, failures }; - }, { - mediaA: mediaA.replace(/\\/g, '/'), - mediaB: mediaB.replace(/\\/g, '/'), - tmpDir: environment.mediaDir.replace(/\\/g, '/') }); await app.close(); diff --git a/scripts/smoke-test-installer.js b/scripts/smoke-test-installer.js new file mode 100644 index 0000000..82fa39c --- /dev/null +++ b/scripts/smoke-test-installer.js @@ -0,0 +1,68 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.resolve(__dirname, '..'); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + ...options, + encoding: 'utf8', + timeout: 240000, + windowsHide: true + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${path.basename(command)} failed: ${JSON.stringify({ status: result.status, signal: result.signal, stdout: result.stdout, stderr: result.stderr })}`); + } + return result; +} + +function findUninstaller(installationDirectory) { + return fs.readdirSync(installationDirectory) + .filter((name) => /^uninstall.*\.exe$/i.test(name)) + .map((name) => path.join(installationDirectory, name))[0] || ''; +} + +function main() { + if (process.platform !== 'win32') throw new Error('Installer smoke requires Windows'); + if (process.env.CI !== 'true' && process.env.TWITCH_VOD_MANAGER_INSTALLER_SMOKE !== '1') { + throw new Error('Installer smoke is restricted to CI or explicit TWITCH_VOD_MANAGER_INSTALLER_SMOKE=1 opt-in'); + } + + const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`); + if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`); + + const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-')); + const installationDirectory = path.join(smokeRoot, 'app'); + const executablePath = path.join(installationDirectory, `${packageJson.build.productName}.exe`); + let uninstallerPath = ''; + + try { + run(installerPath, ['/S', `/D=${installationDirectory}`], { cwd: smokeRoot }); + if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`); + uninstallerPath = findUninstaller(installationDirectory); + if (!uninstallerPath) throw new Error('Installed uninstaller is missing'); + run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], { + cwd: root, + env: { ...process.env, PACKAGED_APP_PATH: executablePath } + }); + run(uninstallerPath, ['/S'], { cwd: smokeRoot }); + if (fs.existsSync(executablePath)) throw new Error('Silent uninstall left the packaged executable installed'); + console.log(JSON.stringify({ failures: [], installerPath }, null, 2)); + } finally { + if (uninstallerPath && fs.existsSync(uninstallerPath)) { + spawnSync(uninstallerPath, ['/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' }); + } + fs.rmSync(smokeRoot, { recursive: true, force: true }); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/scripts/smoke-test-packaged-launch.js b/scripts/smoke-test-packaged-launch.js new file mode 100644 index 0000000..2bbbcff --- /dev/null +++ b/scripts/smoke-test-packaged-launch.js @@ -0,0 +1,91 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const root = path.resolve(__dirname, '..'); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + +function packagedExecutablePath() { + if (process.env.PACKAGED_APP_PATH) return path.resolve(process.env.PACKAGED_APP_PATH); + return path.join(root, 'release', 'win-unpacked', `${packageJson.build.productName}.exe`); +} + +function terminateProcessTree(child) { + if (!child || child.exitCode !== null) return; + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' }); + return; + } + child.kill('SIGTERM'); +} + +async function verifyPackagedLaunch(executablePath = packagedExecutablePath(), readyMs = 5000) { + if (process.platform !== 'win32') throw new Error('Packaged launch smoke requires Windows'); + if (!fs.statSync(executablePath).isFile()) throw new Error(`Packaged executable is missing: ${executablePath}`); + + const environmentRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-packaged-launch-')); + const userDataDir = path.join(environmentRoot, 'userdata'); + const programDataDir = path.join(environmentRoot, 'programdata'); + const appDataDir = path.join(environmentRoot, 'appdata'); + const localAppDataDir = path.join(environmentRoot, 'localappdata'); + const tempDir = path.join(environmentRoot, 'temp'); + for (const directory of [userDataDir, programDataDir, appDataDir, localAppDataDir, tempDir]) { + fs.mkdirSync(directory, { recursive: true }); + } + + let output = ''; + const child = spawn(executablePath, [ + `--user-data-dir=${userDataDir}`, + '--proxy-server=http://127.0.0.1:1', + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost' + ], { + cwd: path.dirname(executablePath), + env: { + ...process.env, + PROGRAMDATA: programDataDir, + APPDATA: appDataDir, + LOCALAPPDATA: localAppDataDir, + TEMP: tempDir, + TMP: tempDir, + HTTP_PROXY: 'http://127.0.0.1:1', + HTTPS_PROXY: 'http://127.0.0.1:1', + ALL_PROXY: 'http://127.0.0.1:1', + NO_PROXY: '' + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }); + + const capture = (chunk) => { + output = `${output}${chunk}`.slice(-32768); + }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + + try { + const result = await Promise.race([ + new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + }), + new Promise((resolve) => setTimeout(() => resolve(null), readyMs)) + ]); + if (result) throw new Error(`Packaged app exited before readiness: ${JSON.stringify({ ...result, output })}`); + return { executablePath, readyMs }; + } finally { + terminateProcessTree(child); + fs.rmSync(environmentRoot, { recursive: true, force: true }); + } +} + +if (require.main === module) { + verifyPackagedLaunch() + .then((result) => console.log(JSON.stringify({ failures: [], result }, null, 2))) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} + +module.exports = { packagedExecutablePath, verifyPackagedLaunch }; diff --git a/src/main/domain/integrity-check.ts b/src/main/domain/integrity-check.ts index 0a12fae..ddfce45 100644 --- a/src/main/domain/integrity-check.ts +++ b/src/main/domain/integrity-check.ts @@ -62,7 +62,7 @@ export function parseFfprobeJson(rawJson: string): ProbeResult { try { parsed = JSON.parse(rawJson) as FfprobeJson; } catch (e) { - throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`); + throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e }); } const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({ diff --git a/src/main/domain/managed-tools.ts b/src/main/domain/managed-tools.ts index fe5b916..8cf0500 100644 --- a/src/main/domain/managed-tools.ts +++ b/src/main/domain/managed-tools.ts @@ -197,8 +197,7 @@ export class ManagedToolInstaller { const existing = this.inFlight.get(manifest.id); if (existing) return existing; - let operation!: Promise; - operation = Promise.resolve() + const operation = Promise.resolve() .then(() => this.installOnce(manifest)) .catch(async (error: unknown) => await this.failure(manifest, 'download-failed', this.errorText(error), [])) .finally(() => { diff --git a/src/main/domain/secret-input.test.ts b/src/main/domain/secret-input.test.ts index c663d9e..b34f57f 100644 --- a/src/main/domain/secret-input.test.ts +++ b/src/main/domain/secret-input.test.ts @@ -22,7 +22,7 @@ describe('resolveSecretInputUpdate', () => { const revision = createSecretInputRevision(); const requestRevision = revision.current(); let resolveSave: (() => void) | undefined; - let visibleValue = 'first-secret'; + let visibleValue: string; const save = new Promise((resolve) => { resolveSave = resolve; }).then(() => { diff --git a/src/main/domain/top-clips-crawler.ts b/src/main/domain/top-clips-crawler.ts index e415c54..37cc86b 100644 --- a/src/main/domain/top-clips-crawler.ts +++ b/src/main/domain/top-clips-crawler.ts @@ -107,7 +107,7 @@ export async function fetchTopClips(opts: FetchTopClipsOptions): Promise:"|?*\x00-\x1f]/g; -const FILENAME_PATH_SEP_RE = /[\\/]/g; +const FILENAME_INVALID_CHARACTERS = new Set('<>:"|?*\\/'); /** * Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem @@ -9,9 +8,9 @@ const FILENAME_PATH_SEP_RE = /[\\/]/g; * nichts uebrig bleibt. */ export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string { - const cleaned = (input || '') - .replace(FILENAME_INVALID_RE, '_') - .replace(FILENAME_PATH_SEP_RE, '_') + const cleaned = Array.from(input || '', (character) => { + return character.charCodeAt(0) < 32 || FILENAME_INVALID_CHARACTERS.has(character) ? '_' : character; + }).join('') .trim(); return cleaned || fallback; }