From 865c3589bd7358aef2ed1f84f5edf03595a7134f Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 24 May 2026 23:20:59 +0200 Subject: [PATCH] Refactor NodeCG runtime preparation and update handling - Updated paths and configurations in doctor.mjs and prepare-nodecg-runtime.mjs to use new build-config.mjs imports. - Enhanced runtime installation checks and permissions validation. - Introduced new update configuration management in update-config.ts, including loading and validating update settings. - Implemented update service for managing update checks and downloads in update-service.ts. - Replaced update-utils.ts with update-schema.ts for better structure and clarity in update handling. - Added comprehensive tests for update download and settings management. - Ensured secure handling of download URLs and improved error handling in update processes. --- docs/refactor/PHASE_4_SUMMARY.md | 121 +++++++++++ scripts/build-config.mjs | 56 +++++ scripts/build-scoreko-bundle.mjs | 44 ++-- scripts/doctor.mjs | 16 +- scripts/prepare-nodecg-runtime.mjs | 58 ++--- scripts/rebuild-nodecg-native.mjs | 19 +- src/main/app/bootstrap.ts | 2 +- src/main/app/paths.ts | 26 +++ src/main/nodecg/runtime-provisioner.ts | 4 +- .../{update-settings.ts => update-config.ts} | 28 ++- src/main/updates/update-dialogs.ts | 2 +- src/main/updates/update-download.ts | 99 +++++++-- src/main/updates/update-schema.ts | 203 ++++++++++++++++++ .../{update-manager.ts => update-service.ts} | 39 +++- src/main/updates/update-utils.ts | 123 ----------- src/tests/app-paths.test.ts | 12 ++ src/tests/update-download.test.ts | 58 +++++ src/tests/update-settings.test.ts | 14 +- src/tests/update-utils.test.ts | 39 +++- 19 files changed, 723 insertions(+), 240 deletions(-) create mode 100644 docs/refactor/PHASE_4_SUMMARY.md create mode 100644 scripts/build-config.mjs rename src/main/updates/{update-settings.ts => update-config.ts} (64%) create mode 100644 src/main/updates/update-schema.ts rename src/main/updates/{update-manager.ts => update-service.ts} (71%) delete mode 100644 src/main/updates/update-utils.ts create mode 100644 src/tests/update-download.test.ts diff --git a/docs/refactor/PHASE_4_SUMMARY.md b/docs/refactor/PHASE_4_SUMMARY.md new file mode 100644 index 0000000..6fd9cbb --- /dev/null +++ b/docs/refactor/PHASE_4_SUMMARY.md @@ -0,0 +1,121 @@ +# Phase 4 Summary + +## Scope + +Executed only the filesystem, updater, and packaging/build-config cleanup requested for this phase. + +Documentation used as source of truth: + +- `docs/refactor/ARCHITECTURE_AUDIT.md` +- `docs/refactor/ARCHITECTURE_RULES.md` +- `docs/refactor/TARGET_ARCHITECTURE.md` +- `docs/refactor/MIGRATION_PLAN.md` +- `docs/refactor/SESSION_HANDOFF.md` + +## Filesystem And Paths + +- Added pure path helpers in `src/main/app/paths.ts` for: + - managed NodeCG runtime storage under Electron `userData` + - default update config location + - update download temp directory + - safe child-path resolution that rejects traversal and absolute-path escape +- Updated runtime provisioning to use the managed-runtime path helper instead of rebuilding that storage path locally. +- Added tests for update storage paths and path traversal rejection. + +## Updater + +- Reorganized updater modules toward the target architecture: + - `src/main/updates/update-service.ts` + - `src/main/updates/update-config.ts` + - `src/main/updates/update-schema.ts` + - `src/main/updates/update-download.ts` +- Removed the older updater module names: + - `update-manager.ts` + - `update-settings.ts` + - `update-utils.ts` +- Added runtime validation for remote Gitea release metadata before building update state. +- Added URL policy handling so packaged builds reject insecure HTTP update URLs and installer downloads. +- Kept local development able to use HTTP update endpoints explicitly through the dev policy. +- Changed installer download behavior to: + - validate URL protocol before fetch + - sanitize installer file names + - constrain output to the safe temp download directory + - write to a staging file first + - finalize with atomic rename + - clean staging files on failure +- Kept dialogs and install handoff separate from schema parsing and download streaming. + +## Packaging And Build Config + +- Added `scripts/build-config.mjs` as the shared build-layout source for scripts. +- Consolidated repeated script constants for: + - Electron package root + - parent Scoreko bundle root + - packaged NodeCG runtime root + - bundle name + - generated bundle entries + - prepared runtime entries + - npm/electron cache locations + - local binary path resolution +- Updated packaging-related scripts to use the shared config: + - `scripts/build-scoreko-bundle.mjs` + - `scripts/prepare-nodecg-runtime.mjs` + - `scripts/rebuild-nodecg-native.mjs` + - `scripts/doctor.mjs` +- Improved the missing parent-project error in `build-scoreko-bundle.mjs` so CI/local failures report the expected layout and missing markers. + +## Intentionally Not Changed + +- No UX changes. +- No custom renderer. +- No preload. +- No IPC. +- No Electron window behavior changes. +- No NodeCG runtime model changes. +- No user-owned runtime directory deletion changes. +- No broad build framework introduced. +- No `any` added. + +## Verification + +Commands run successfully: + +```text +npm.cmd run typecheck +npm.cmd test +npm.cmd run lint +npm.cmd run doctor +``` + +Current test result: + +```text +65 tests passing +``` + +Packaging verification: + +```text +npm.cmd run pack +``` + +Result: + +- Passed with escalated filesystem permission, generating `release/win-unpacked`. +- A later non-escalated rerun was blocked by the sandbox while writing generated bundle output in the parent Scoreko project (`shared/dist`). That rerun failed before packaging because of sandbox filesystem permissions, not because of a build error. +- A final escalated rerun could not be started because the approval system rejected the escalation. Typecheck, tests, lint, and doctor were run successfully around the packaging verification. + +Sanity searches: + +```text +rg -n "\bany\b|update-manager|update-settings|update-utils|ActualizaciÃ|estÃ|versiÃ|nodeIntegration:\s*true|webSecurity:\s*false|ipcMain|ipcRenderer|contextBridge|preload" src scripts docs/refactor +``` + +Result: + +- No `any` was introduced in production or test source. +- No legacy updater module references remain in `src`. +- No touched Spanish update text is mojibaked. +- No production IPC or preload surface exists. +- No unsafe Electron window settings were introduced. +- Remaining IPC/preload matches are documentation and the regression test that guards the zero-surface policy. diff --git a/scripts/build-config.mjs b/scripts/build-config.mjs new file mode 100644 index 0000000..78d1a18 --- /dev/null +++ b/scripts/build-config.mjs @@ -0,0 +1,56 @@ +import path from "node:path"; + +export const electronRoot = process.cwd(); +export const bundleRoot = path.resolve(electronRoot, ".."); +export const nodecgRuntimeRoot = path.join(electronRoot, "lib", "nodecg"); +export const nodecgRuntimeNodeModules = path.join(nodecgRuntimeRoot, "node_modules"); +export const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev"; +export const runtimeBundleRoot = path.join(nodecgRuntimeRoot, "bundles", bundleName); +export const runtimeNpmCache = process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache"); +export const electronCache = process.env.ELECTRON_CACHE ?? path.join(electronRoot, ".electron-cache"); + +export const bundleRootMarkers = ["package.json", "pnpm-lock.yaml"]; +export const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"]; +export const preparedBundleEntries = [ + "assets", + "dashboard", + "extension", + "graphics", + "nodecg", + "schemas", + "shared", + "configschema.json", + "LICENSE", + "package.json", + "README.md", +]; +export const requiredPreparedBundleEntries = [ + "dashboard", + "extension", + "graphics", + "nodecg", + "schemas", + "shared", + "package.json", +]; + +export function getNpmCommand() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +export function getLocalBinPath(commandName) { + const extension = process.platform === "win32" ? ".CMD" : ""; + return path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`); +} + +export function getPathInside(rootPath, relativePath) { + const resolvedRoot = path.resolve(rootPath); + const targetPath = path.resolve(resolvedRoot, relativePath); + const pathFromRoot = path.relative(resolvedRoot, targetPath); + + if (!pathFromRoot || pathFromRoot.startsWith("..") || path.isAbsolute(pathFromRoot)) { + throw new Error(`Refusing to access path outside ${resolvedRoot}: ${targetPath}`); + } + + return targetPath; +} diff --git a/scripts/build-scoreko-bundle.mjs b/scripts/build-scoreko-bundle.mjs index eceaf79..e0cfb31 100644 --- a/scripts/build-scoreko-bundle.mjs +++ b/scripts/build-scoreko-bundle.mjs @@ -3,14 +3,29 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; -const electronRoot = process.cwd(); -const bundleRoot = path.resolve(electronRoot, ".."); -const packageJsonPath = path.join(bundleRoot, "package.json"); -const pnpmLockPath = path.join(bundleRoot, "pnpm-lock.yaml"); +import { + bundleRoot, + bundleRootMarkers, + electronRoot, + generatedBundleEntries, + getLocalBinPath, + getPathInside, +} from "./build-config.mjs"; + const nodeModulesPath = path.join(bundleRoot, "node_modules"); -if (!existsSync(packageJsonPath) || !existsSync(pnpmLockPath)) { - console.error(`Scoreko bundle root was not found at: ${bundleRoot}`); +const missingMarkers = bundleRootMarkers + .map((entry) => path.join(bundleRoot, entry)) + .filter((candidatePath) => !existsSync(candidatePath)); + +if (missingMarkers.length > 0) { + console.error( + [ + `Scoreko bundle root was not found at: ${bundleRoot}`, + "This Electron package expects to live inside the Scoreko repository with the bundle project as its parent.", + ...missingMarkers.map((candidatePath) => `Missing: ${candidatePath}`), + ].join("\n"), + ); process.exit(1); } @@ -25,7 +40,6 @@ if (!existsSync(nodeModulesPath)) { process.exit(1); } -const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"]; const childEnv = { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"), @@ -33,12 +47,7 @@ const childEnv = { }; function removeGeneratedOutput(relativePath) { - const targetPath = path.resolve(bundleRoot, relativePath); - - if (!targetPath.startsWith(`${bundleRoot}${path.sep}`)) { - throw new Error(`Refusing to remove path outside the bundle root: ${targetPath}`); - } - + const targetPath = getPathInside(bundleRoot, relativePath); rmSync(targetPath, { recursive: true, force: true }); } @@ -60,11 +69,6 @@ function runCommand(command, args) { } } -function runLocalBin(commandName, args) { - const extension = process.platform === "win32" ? ".CMD" : ""; - runCommand(path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`), args); -} - for (const entry of generatedBundleEntries) { removeGeneratedOutput(entry); } @@ -73,5 +77,5 @@ for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) { mkdirSync(path.join(bundleRoot, entry), { recursive: true }); } -runLocalBin("vite", ["build", "--configLoader", "runner"]); -runLocalBin("tsc", ["-b", "tsconfig.extension.json"]); +runCommand(getLocalBinPath("vite"), ["build", "--configLoader", "runner"]); +runCommand(getLocalBinPath("tsc"), ["-b", "tsconfig.extension.json"]); diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 323f423..e36d6f3 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -3,8 +3,7 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; -const cwd = process.cwd(); -const nodecgRootPath = path.resolve(cwd, "lib", "nodecg"); +import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs"; const checks = []; @@ -36,20 +35,19 @@ function parseIntInRange(name, fallback, min, max) { } function checkNodecgInstall() { - const indexPath = path.join(nodecgRootPath, "index.js"); - const bootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); - const manifestPath = path.join(nodecgRootPath, ".scoreko-runtime.json"); - const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim(); - const bundlePath = path.join(nodecgRootPath, "bundles", bundleName); + const indexPath = path.join(nodecgRuntimeRoot, "index.js"); + const bootstrapPath = path.join(nodecgRuntimeRoot, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); + const manifestPath = path.join(nodecgRuntimeRoot, ".scoreko-runtime.json"); + const bundlePath = path.join(nodecgRuntimeRoot, "bundles", bundleName); - addCheck(fs.existsSync(nodecgRootPath), "Packaged NodeCG runtime", nodecgRootPath); + addCheck(fs.existsSync(nodecgRuntimeRoot), "Packaged NodeCG runtime", nodecgRuntimeRoot); addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath); addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath); addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath); addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath); try { - fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK); + fs.accessSync(nodecgRuntimeRoot, fs.constants.R_OK | fs.constants.W_OK); addCheck(true, "lib/nodecg permissions", "Read/write OK for local development"); } catch { addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg"); diff --git a/scripts/prepare-nodecg-runtime.mjs b/scripts/prepare-nodecg-runtime.mjs index 99f4176..782e5c2 100644 --- a/scripts/prepare-nodecg-runtime.mjs +++ b/scripts/prepare-nodecg-runtime.mjs @@ -3,28 +3,17 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } fr import path from "node:path"; import { spawnSync } from "node:child_process"; -const electronRoot = process.cwd(); -const bundleRoot = path.resolve(electronRoot, ".."); -const runtimeRoot = path.join(electronRoot, "lib", "nodecg"); -const runtimeNodeModules = path.join(runtimeRoot, "node_modules"); -const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev"; -const runtimeBundleRoot = path.join(runtimeRoot, "bundles", bundleName); - -const bundleEntries = [ - "assets", - "dashboard", - "extension", - "graphics", - "nodecg", - "schemas", - "shared", - "configschema.json", - "LICENSE", - "package.json", - "README.md", -]; - -const requiredBundleEntries = ["dashboard", "extension", "graphics", "nodecg", "schemas", "shared", "package.json"]; +import { + bundleName, + bundleRoot, + getNpmCommand, + nodecgRuntimeNodeModules, + nodecgRuntimeRoot, + preparedBundleEntries, + requiredPreparedBundleEntries, + runtimeBundleRoot, + runtimeNpmCache, +} from "./build-config.mjs"; function readJson(filePath) { return JSON.parse(readFileSync(filePath, "utf8")); @@ -51,7 +40,7 @@ function run(command, args, cwd) { shell: process.platform === "win32", env: { ...process.env, - npm_config_cache: process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache"), + npm_config_cache: runtimeNpmCache, }, }); @@ -81,7 +70,7 @@ function getInstalledNodecgVersion() { } function assertBundleBuildExists() { - for (const entry of requiredBundleEntries) { + for (const entry of requiredPreparedBundleEntries) { const source = path.join(bundleRoot, entry); if (!existsSync(source)) { throw new Error( @@ -103,7 +92,7 @@ function createRuntimePackageJson() { }; writeFileSync( - path.join(runtimeRoot, "package.json"), + path.join(nodecgRuntimeRoot, "package.json"), `${JSON.stringify( { private: true, @@ -121,23 +110,23 @@ function createRuntimePackageJson() { )}\n`, ); - writeFileSync(path.join(runtimeRoot, "index.js"), 'require("nodecg");\n'); + writeFileSync(path.join(nodecgRuntimeRoot, "index.js"), 'require("nodecg");\n'); } function copyBundle() { mkdirSync(runtimeBundleRoot, { recursive: true }); - for (const entry of bundleEntries) { + for (const entry of preparedBundleEntries) { copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry)); } } function writeManifest() { const bundlePackageJson = readJson(path.join(bundleRoot, "package.json")); - const runtimePackageJson = readJson(path.join(runtimeRoot, "package.json")); + const runtimePackageJson = readJson(path.join(nodecgRuntimeRoot, "package.json")); writeFileSync( - path.join(runtimeRoot, ".scoreko-runtime.json"), + path.join(nodecgRuntimeRoot, ".scoreko-runtime.json"), `${JSON.stringify( { bundleName, @@ -157,23 +146,22 @@ function installRuntimeDependencies() { return; } - const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; - run(npmCommand, ["install", "--omit=dev", "--no-audit", "--no-fund"], runtimeRoot); + run(getNpmCommand(), ["install", "--omit=dev", "--no-audit", "--no-fund"], nodecgRuntimeRoot); } function main() { assertBundleBuildExists(); - rmSync(runtimeRoot, { recursive: true, force: true }); - mkdirSync(runtimeNodeModules, { recursive: true }); - mkdirSync(path.join(runtimeRoot, "bundles"), { recursive: true }); + rmSync(nodecgRuntimeRoot, { recursive: true, force: true }); + mkdirSync(nodecgRuntimeNodeModules, { recursive: true }); + mkdirSync(path.join(nodecgRuntimeRoot, "bundles"), { recursive: true }); createRuntimePackageJson(); copyBundle(); installRuntimeDependencies(); writeManifest(); - console.log(`[prepare-runtime] NodeCG runtime ready at ${runtimeRoot}`); + console.log(`[prepare-runtime] NodeCG runtime ready at ${nodecgRuntimeRoot}`); } try { diff --git a/scripts/rebuild-nodecg-native.mjs b/scripts/rebuild-nodecg-native.mjs index 9e0d7e7..baf58c4 100644 --- a/scripts/rebuild-nodecg-native.mjs +++ b/scripts/rebuild-nodecg-native.mjs @@ -2,18 +2,17 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; -const root = process.cwd(); -const nodecgDir = path.join(root, "lib", "nodecg"); -const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); +import { electronCache, electronRoot, getNpmCommand, nodecgRuntimeRoot, runtimeNpmCache } from "./build-config.mjs"; + +const packageJson = JSON.parse(readFileSync(path.join(electronRoot, "package.json"), "utf8")); const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron; -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; if (!electronVersion) { console.error("Could not determine Electron version from package.json."); process.exit(1); } -if (!existsSync(path.join(nodecgDir, "package.json"))) { +if (!existsSync(path.join(nodecgRuntimeRoot, "package.json"))) { console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first."); process.exit(1); } @@ -30,8 +29,8 @@ function run(command, args, cwd) { npm_config_runtime: "electron", npm_config_target: electronVersion, npm_config_disturl: "https://electronjs.org/headers", - npm_config_cache: process.env.npm_config_cache ?? path.join(root, ".npm-runtime-cache"), - ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(root, ".electron-cache"), + npm_config_cache: runtimeNpmCache, + ELECTRON_CACHE: electronCache, }, }); @@ -45,13 +44,13 @@ function run(command, args, cwd) { }); } -console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgDir}`); -await run(npmCommand, [ +console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgRuntimeRoot}`); +await run(getNpmCommand(), [ "rebuild", "better-sqlite3", "--runtime=electron", `--target=${electronVersion}`, "--dist-url=https://electronjs.org/headers", -], nodecgDir); +], nodecgRuntimeRoot); console.log("\n[rebuild-native] Done."); diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts index 5657388..26ee8ee 100644 --- a/src/main/app/bootstrap.ts +++ b/src/main/app/bootstrap.ts @@ -5,7 +5,7 @@ import { getRuntimeConfig } from "../config/runtime-config"; import { showFatalError, log } from "../errors/error-presenter"; import { createNodecgProcessManager } from "../nodecg/process-manager"; import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner"; -import { scheduleUpdateCheck } from "../updates/update-manager"; +import { scheduleUpdateCheck } from "../updates/update-service"; import { createLoadingWindow, createMainWindow } from "../windows/window-service"; import { createApplicationController } from "./application-controller"; import { getApplicationPaths } from "./paths"; diff --git a/src/main/app/paths.ts b/src/main/app/paths.ts index 8323a58..6e4b8b6 100644 --- a/src/main/app/paths.ts +++ b/src/main/app/paths.ts @@ -19,10 +19,36 @@ export function getUserDataPath(appDataPath: string, userDataDirectoryName: stri return path.join(appDataPath, userDataDirectoryName); } +export function getManagedNodecgRuntimePath(userDataPath: string): string { + return path.join(userDataPath, "nodecg"); +} + export function getSourceNodecgRuntimePath(rootPath: string): string { return path.resolve(rootPath, "lib", "nodecg"); } +export function getDefaultUpdateConfigPath(rootPath: string): string { + return path.join(rootPath, "static", "updates.json"); +} + +export function getUpdateDownloadDirectory(tempDirectory: string): string { + return path.join(tempDirectory, "scoreko-updates"); +} + +export function getSafeChildPath(parentDirectory: string, fileName: string): string { + const resolvedParent = path.resolve(parentDirectory); + const resolvedChild = path.resolve(resolvedParent, fileName); + const relativePath = path.relative(resolvedParent, resolvedChild); + const isInsideParent = + relativePath.length > 0 && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + + if (!isInsideParent) { + throw new Error(`Refusing to build a path outside ${resolvedParent}: ${fileName}`); + } + + return resolvedChild; +} + export function getNodecgBaseUrl(nodecgPort: string): string { return `http://127.0.0.1:${nodecgPort}`; } diff --git a/src/main/nodecg/runtime-provisioner.ts b/src/main/nodecg/runtime-provisioner.ts index fc4eec2..631ce38 100644 --- a/src/main/nodecg/runtime-provisioner.ts +++ b/src/main/nodecg/runtime-provisioner.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import path from "node:path"; +import { getManagedNodecgRuntimePath } from "../app/paths"; + type RuntimeProvisionerConfig = { sourceRuntimePath: string; userDataPath: string; @@ -55,7 +57,7 @@ export function prepareUserNodecgRuntime({ deps, }: RuntimeProvisionerConfig): PreparedNodecgRuntime { const resolvedDeps = resolveDeps(deps); - const targetRuntimePath = path.join(userDataPath, "nodecg"); + const targetRuntimePath = getManagedNodecgRuntimePath(userDataPath); validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync); resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true }); diff --git a/src/main/updates/update-settings.ts b/src/main/updates/update-config.ts similarity index 64% rename from src/main/updates/update-settings.ts rename to src/main/updates/update-config.ts index acbd2ac..f43e721 100644 --- a/src/main/updates/update-settings.ts +++ b/src/main/updates/update-config.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; -import path from "node:path"; +import { getDefaultUpdateConfigPath } from "../app/paths"; import { AppRuntimeConfig } from "../config/runtime-config"; -import { UpdateFileConfig } from "./update-utils"; +import { UpdateFileConfig, validateHttpUrl } from "./update-schema"; const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$"; @@ -13,17 +13,24 @@ export type UpdateSettings = { assetPattern: string; }; +type UpdateConfigOptions = { + allowInsecureHttp: boolean; +}; + export function loadUpdateSettings( appConfig: AppRuntimeConfig, rootPath: string, log: (...args: unknown[]) => void, + options: UpdateConfigOptions = { allowInsecureHttp: true }, ): UpdateSettings { const fileConfig = readUpdateFileConfig(appConfig, rootPath, log); + const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl ?? fileConfig.apiUrl, options); + const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl ?? fileConfig.releasePageUrl, options); return { - enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)), - apiUrl: appConfig.updateApiUrl ?? readOptionalString(fileConfig.apiUrl), - releasePageUrl: appConfig.updateReleasePageUrl ?? readOptionalString(fileConfig.releasePageUrl), + enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)) && Boolean(apiUrl), + ...(apiUrl ? { apiUrl } : {}), + ...(releasePageUrl ? { releasePageUrl } : {}), assetPattern: appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN, }; @@ -34,7 +41,7 @@ export function readUpdateFileConfig( rootPath: string, log: (...args: unknown[]) => void, ): UpdateFileConfig { - const configPath = appConfig.updateConfigPathOverride ?? path.join(rootPath, "static", "updates.json"); + const configPath = appConfig.updateConfigPathOverride ?? getDefaultUpdateConfigPath(rootPath); if (!fs.existsSync(configPath)) { return {}; @@ -62,6 +69,15 @@ function normalizeUpdateFileConfig(value: unknown): UpdateFileConfig { }; } +function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined { + const rawValue = readOptionalString(value); + if (!rawValue) { + return undefined; + } + + return validateHttpUrl(rawValue, options) ?? undefined; +} + function readOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } diff --git a/src/main/updates/update-dialogs.ts b/src/main/updates/update-dialogs.ts index 75d4549..0934296 100644 --- a/src/main/updates/update-dialogs.ts +++ b/src/main/updates/update-dialogs.ts @@ -1,7 +1,7 @@ import { BrowserWindow, dialog } from "electron"; import type { MessageBoxOptions } from "electron"; -import { ReleaseUpdate } from "./update-utils"; +import { ReleaseUpdate } from "./update-schema"; export type DownloadUpdateChoice = "download" | "open-release" | "dismiss"; diff --git a/src/main/updates/update-download.ts b/src/main/updates/update-download.ts index 54d3511..5e58d51 100644 --- a/src/main/updates/update-download.ts +++ b/src/main/updates/update-download.ts @@ -1,34 +1,103 @@ import fs from "node:fs"; -import path from "node:path"; -import { Readable } from "node:stream"; +import { Writable } from "node:stream"; -import { ReleaseUpdate, sanitizeFileName } from "./update-utils"; +import { getSafeChildPath, getUpdateDownloadDirectory } from "../app/paths"; +import { ReleaseUpdate, sanitizeFileName, validateHttpUrl } from "./update-schema"; type UpdateDownloadConfig = { tempDirectory: string; + allowInsecureHttp: boolean; }; export async function downloadInstaller(update: ReleaseUpdate, config: UpdateDownloadConfig): Promise { + const downloadUrl = validateHttpUrl(update.installer.downloadUrl, { + allowInsecureHttp: config.allowInsecureHttp, + }); + + if (!downloadUrl) { + throw new Error("Update installer URL is invalid or uses an unsupported protocol."); + } + const safeFileName = sanitizeFileName(update.installer.name); - const downloadDirectory = path.join(config.tempDirectory, "scoreko-updates"); - const targetPath = path.join(downloadDirectory, safeFileName); + const downloadDirectory = getUpdateDownloadDirectory(config.tempDirectory); + const targetPath = getSafeChildPath(downloadDirectory, safeFileName); + const stagingPath = getSafeChildPath(downloadDirectory, `${safeFileName}.${process.pid}.${Date.now()}.download`); fs.mkdirSync(downloadDirectory, { recursive: true }); + fs.rmSync(stagingPath, { force: true }); - const response = await fetch(update.installer.downloadUrl); + const response = await fetch(downloadUrl); if (!response.ok || !response.body) { throw new Error(`Could not download update installer. HTTP ${response.status}.`); } - await new Promise((resolve, reject) => { - const fileStream = fs.createWriteStream(targetPath); - const responseStream = Readable.fromWeb(response.body as Parameters[0]); - - responseStream.on("error", reject); - fileStream.on("error", reject); - fileStream.on("finish", resolve); - responseStream.pipe(fileStream); - }); + try { + await writeResponseBodyToFile(response.body, stagingPath); + fs.renameSync(stagingPath, targetPath); + } catch (error) { + fs.rmSync(stagingPath, { force: true }); + throw error; + } return targetPath; } + +async function writeResponseBodyToFile(body: ReadableStream, filePath: string): Promise { + const reader = body.getReader(); + const fileStream = fs.createWriteStream(filePath, { flags: "wx" }); + + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + + await writeChunk(fileStream, chunk.value); + } + + await endStream(fileStream); + } catch (error) { + fileStream.destroy(); + throw error; + } finally { + reader.releaseLock(); + } +} + +function writeChunk(stream: Writable, chunk: Uint8Array): Promise { + if (stream.write(chunk)) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const cleanup = (): void => { + stream.off("drain", onDrain); + stream.off("error", onError); + }; + const onDrain = (): void => { + cleanup(); + resolve(); + }; + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + + stream.once("drain", onDrain); + stream.once("error", onError); + }); +} + +function endStream(stream: Writable): Promise { + return new Promise((resolve, reject) => { + stream.end((error?: Error | null) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} diff --git a/src/main/updates/update-schema.ts b/src/main/updates/update-schema.ts new file mode 100644 index 0000000..8e6334b --- /dev/null +++ b/src/main/updates/update-schema.ts @@ -0,0 +1,203 @@ +export type GiteaReleaseAsset = { + name: string; + browserDownloadUrl: string; + size?: number; +}; + +export type GiteaRelease = { + tagName: string; + title?: string; + pageUrl?: string; + assets: GiteaReleaseAsset[]; +}; + +export type InstallerAsset = { + name: string; + downloadUrl: string; + size?: number; +}; + +export type ReleaseUpdate = { + version: string; + title: string; + pageUrl?: string; + installer: InstallerAsset; +}; + +export type UpdateFileConfig = { + enabled?: unknown; + apiUrl?: unknown; + releasePageUrl?: unknown; + assetPattern?: unknown; +}; + +type UrlPolicy = { + allowInsecureHttp: boolean; +}; + +export function parseGiteaRelease(value: unknown): GiteaRelease | null { + if (!isRecord(value)) { + return null; + } + + const tagName = readRequiredString(value.tag_name); + const assets = Array.isArray(value.assets) ? value.assets.map(parseGiteaReleaseAsset).filter(isPresent) : null; + + if (!tagName || !assets) { + return null; + } + + return { + tagName, + assets, + ...(readOptionalString(value.name) ? { title: readOptionalString(value.name) } : {}), + ...(readOptionalUrlString(value.html_url) ? { pageUrl: readOptionalUrlString(value.html_url) } : {}), + }; +} + +export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean { + const candidate = normalizeVersion(candidateVersion); + const current = normalizeVersion(currentVersion); + + for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) { + const candidatePart = candidate[index] ?? 0; + const currentPart = current[index] ?? 0; + + if (candidatePart > currentPart) { + return true; + } + + if (candidatePart < currentPart) { + return false; + } + } + + return false; +} + +export function selectInstallerAsset( + release: GiteaRelease, + assetPattern: string, + policy: UrlPolicy = { allowInsecureHttp: true }, +): InstallerAsset | null { + const matcher = new RegExp(assetPattern, "i"); + + for (const asset of release.assets) { + if (!matcher.test(asset.name)) { + continue; + } + + const downloadUrl = validateHttpUrl(asset.browserDownloadUrl, policy); + if (!downloadUrl) { + continue; + } + + return { + name: asset.name, + downloadUrl, + ...(typeof asset.size === "number" ? { size: asset.size } : {}), + }; + } + + return null; +} + +export function buildReleaseUpdate( + release: GiteaRelease, + currentVersion: string, + assetPattern: string, + policy: UrlPolicy = { allowInsecureHttp: true }, +): ReleaseUpdate | null { + const version = release.tagName.replace(/^v/i, ""); + if (!version || !isVersionNewer(version, currentVersion)) { + return null; + } + + const installer = selectInstallerAsset(release, assetPattern, policy); + if (!installer) { + return null; + } + + const pageUrl = release.pageUrl ? validateHttpUrl(release.pageUrl, policy) ?? undefined : undefined; + + return { + version, + title: release.title ?? `Scoreko ${version}`, + ...(pageUrl ? { pageUrl } : {}), + installer, + }; +} + +export function sanitizeFileName(fileName: string): string { + const sanitized = fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim(); + return sanitized.length > 0 ? sanitized : "scoreko-update-installer"; +} + +export function validateHttpUrl(value: string, policy: UrlPolicy): string | null { + try { + const url = new URL(value); + + if (url.protocol === "https:" || (policy.allowInsecureHttp && url.protocol === "http:")) { + return url.toString(); + } + + return null; + } catch { + return null; + } +} + +function parseGiteaReleaseAsset(value: unknown): GiteaReleaseAsset | null { + if (!isRecord(value)) { + return null; + } + + const name = readRequiredString(value.name); + const browserDownloadUrl = readRequiredString(value.browser_download_url); + + if (!name || !browserDownloadUrl) { + return null; + } + + return { + name, + browserDownloadUrl, + ...(typeof value.size === "number" && value.size >= 0 ? { size: value.size } : {}), + }; +} + +function normalizeVersion(version: string): number[] { + return version + .trim() + .replace(/^v/i, "") + .split(/[+-]/)[0] + .split(".") + .map((part) => Number.parseInt(part, 10)) + .map((part) => (Number.isFinite(part) ? part : 0)); +} + +function readRequiredString(value: unknown): string | null { + const text = readOptionalString(value); + return text && text.length > 0 ? text : null; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function readOptionalUrlString(value: unknown): string | undefined { + const rawValue = readOptionalString(value); + if (!rawValue) { + return undefined; + } + + return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPresent(value: T | null): value is T { + return value !== null; +} diff --git a/src/main/updates/update-manager.ts b/src/main/updates/update-service.ts similarity index 71% rename from src/main/updates/update-manager.ts rename to src/main/updates/update-service.ts index 9aafaf7..5b74477 100644 --- a/src/main/updates/update-manager.ts +++ b/src/main/updates/update-service.ts @@ -2,11 +2,11 @@ import { app, BrowserWindow, shell } from "electron"; import { AppRuntimeConfig } from "../config/runtime-config"; import { askToDownloadUpdate, askToInstallUpdate } from "./update-dialogs"; +import { loadUpdateSettings, UpdateSettings } from "./update-config"; import { downloadInstaller } from "./update-download"; -import { loadUpdateSettings, UpdateSettings } from "./update-settings"; -import { buildReleaseUpdate, GiteaRelease } from "./update-utils"; +import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema"; -type UpdateManagerConfig = { +type UpdateServiceConfig = { appConfig: AppRuntimeConfig; rootPath: string; getParentWindow: () => BrowserWindow | null; @@ -14,14 +14,19 @@ type UpdateManagerConfig = { log: (...args: unknown[]) => void; }; +type UpdateProtocolPolicy = { + allowInsecureHttp: boolean; +}; + export function scheduleUpdateCheck({ appConfig, rootPath, getParentWindow, beforeInstall, log, -}: UpdateManagerConfig): void { - const settings = loadUpdateSettings(appConfig, rootPath, log); +}: UpdateServiceConfig): void { + const protocolPolicy = getUpdateProtocolPolicy(); + const settings = loadUpdateSettings(appConfig, rootPath, log, protocolPolicy); if (!settings.enabled || !settings.apiUrl) { log("Update checks disabled or not configured."); @@ -29,7 +34,7 @@ export function scheduleUpdateCheck({ } setTimeout(() => { - void checkForUpdates({ settings, getParentWindow, beforeInstall, log }); + void checkForUpdates({ settings, getParentWindow, beforeInstall, log, protocolPolicy }); }, appConfig.updateCheckDelayMs); } @@ -38,11 +43,13 @@ async function checkForUpdates({ getParentWindow, beforeInstall, log, + protocolPolicy, }: { settings: UpdateSettings; getParentWindow: () => BrowserWindow | null; beforeInstall: () => Promise; log: (...args: unknown[]) => void; + protocolPolicy: UpdateProtocolPolicy; }): Promise { try { if (!settings.apiUrl) { @@ -50,7 +57,7 @@ async function checkForUpdates({ } const release = await fetchLatestRelease(settings.apiUrl); - const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern); + const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern, protocolPolicy); if (!update) { log("No Scoreko update available."); @@ -69,7 +76,10 @@ async function checkForUpdates({ return; } - const installerPath = await downloadInstaller(update, { tempDirectory: app.getPath("temp") }); + const installerPath = await downloadInstaller(update, { + tempDirectory: app.getPath("temp"), + allowInsecureHttp: protocolPolicy.allowInsecureHttp, + }); const shouldInstall = await askToInstallUpdate(update, getParentWindow()); if (!shouldInstall) { await shell.showItemInFolder(installerPath); @@ -99,7 +109,12 @@ async function fetchLatestRelease(apiUrl: string): Promise { throw new Error(`Gitea update check failed with HTTP ${response.status}.`); } - return (await response.json()) as GiteaRelease; + const release = parseGiteaRelease(await response.json()); + if (!release) { + throw new Error("Gitea update metadata is invalid."); + } + + return release; } async function openReleasePage(releasePageUrl: string | undefined): Promise { @@ -107,3 +122,9 @@ async function openReleasePage(releasePageUrl: string | undefined): Promise currentPart) { - return true; - } - - if (candidatePart < currentPart) { - return false; - } - } - - return false; -} - -export function getReleaseVersion(release: GiteaRelease): string | null { - const tagName = typeof release.tag_name === "string" ? release.tag_name.trim() : ""; - return tagName.length > 0 ? tagName.replace(/^v/i, "") : null; -} - -export function getReleaseTitle(release: GiteaRelease, version: string): string { - const releaseName = typeof release.name === "string" ? release.name.trim() : ""; - return releaseName.length > 0 ? releaseName : `Scoreko ${version}`; -} - -export function selectInstallerAsset(release: GiteaRelease, assetPattern: string): InstallerAsset | null { - const assets = Array.isArray(release.assets) ? release.assets : []; - const matcher = new RegExp(assetPattern, "i"); - - for (const asset of assets as GiteaReleaseAsset[]) { - const name = typeof asset.name === "string" ? asset.name : ""; - const downloadUrl = typeof asset.browser_download_url === "string" ? asset.browser_download_url : ""; - - if (!name || !downloadUrl || !matcher.test(name)) { - continue; - } - - return { - name, - downloadUrl, - ...(typeof asset.size === "number" ? { size: asset.size } : {}), - }; - } - - return null; -} - -export function buildReleaseUpdate( - release: GiteaRelease, - currentVersion: string, - assetPattern: string, -): ReleaseUpdate | null { - const version = getReleaseVersion(release); - if (!version || !isVersionNewer(version, currentVersion)) { - return null; - } - - const installer = selectInstallerAsset(release, assetPattern); - if (!installer) { - return null; - } - - const pageUrl = typeof release.html_url === "string" && release.html_url.length > 0 ? release.html_url : undefined; - - return { - version, - title: getReleaseTitle(release, version), - pageUrl, - installer, - }; -} - -export function sanitizeFileName(fileName: string): string { - return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_"); -} - -function normalizeVersion(version: string): number[] { - return version - .trim() - .replace(/^v/i, "") - .split(/[+-]/)[0] - .split(".") - .map((part) => Number.parseInt(part, 10)) - .map((part) => (Number.isFinite(part) ? part : 0)); -} diff --git a/src/tests/app-paths.test.ts b/src/tests/app-paths.test.ts index 2bb7b28..f8da921 100644 --- a/src/tests/app-paths.test.ts +++ b/src/tests/app-paths.test.ts @@ -5,9 +5,13 @@ import test from "node:test"; import { getApplicationPaths, getDashboardUrl, + getDefaultUpdateConfigPath, + getManagedNodecgRuntimePath, getNodecgBaseUrl, getRootPath, + getSafeChildPath, getSourceNodecgRuntimePath, + getUpdateDownloadDirectory, getUserDataPath, } from "../main/app/paths"; @@ -18,6 +22,9 @@ test("app path helpers build deterministic development paths and URLs", () => { assert.equal(rootPath, path.resolve(compiledMainDir, "../..")); assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg")); assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko")); + assert.equal(getManagedNodecgRuntimePath("/app-data/scoreko"), path.join("/app-data/scoreko", "nodecg")); + assert.equal(getDefaultUpdateConfigPath(rootPath), path.join(rootPath, "static", "updates.json")); + assert.equal(getUpdateDownloadDirectory("/tmp"), path.join("/tmp", "scoreko-updates")); assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090"); assert.equal( getDashboardUrl("9090", "scoreko-dev", "dashboard/main.html?standalone=true"), @@ -45,3 +52,8 @@ test("getApplicationPaths keeps packaged root under Electron resources", () => { assert.equal(paths.userDataPath, path.join("/users/test/AppData/Roaming", "scoreko")); assert.equal(paths.nodecgBaseUrl, "http://127.0.0.1:9090"); }); + +test("getSafeChildPath rejects path traversal", () => { + assert.equal(getSafeChildPath("/tmp/scoreko-updates", "setup.exe"), path.resolve("/tmp/scoreko-updates/setup.exe")); + assert.throws(() => getSafeChildPath("/tmp/scoreko-updates", "../setup.exe"), /outside/); +}); diff --git a/src/tests/update-download.test.ts b/src/tests/update-download.test.ts new file mode 100644 index 0000000..566b891 --- /dev/null +++ b/src/tests/update-download.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { downloadInstaller } from "../main/updates/update-download"; + +test("downloadInstaller writes into the update temp directory and removes staging files", async () => { + const previousFetch = globalThis.fetch; + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-")); + + globalThis.fetch = async () => new Response("installer-bytes"); + + try { + const installerPath = await downloadInstaller( + { + version: "0.2.0", + title: "Scoreko 0.2.0", + installer: { + name: "Scoreko/setup:0.2.0.exe", + downloadUrl: "https://updates.local/Scoreko-setup-0.2.0.exe", + }, + }, + { tempDirectory, allowInsecureHttp: false }, + ); + + const downloadDirectory = path.join(tempDirectory, "scoreko-updates"); + assert.equal(installerPath, path.join(downloadDirectory, "Scoreko_setup_0.2.0.exe")); + assert.equal(fs.readFileSync(installerPath, "utf8"), "installer-bytes"); + assert.deepEqual( + fs.readdirSync(downloadDirectory).filter((entry) => entry.endsWith(".download")), + [], + ); + } finally { + globalThis.fetch = previousFetch; + } +}); + +test("downloadInstaller rejects insecure production download URLs", async () => { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-")); + + await assert.rejects( + () => + downloadInstaller( + { + version: "0.2.0", + title: "Scoreko 0.2.0", + installer: { + name: "Scoreko-setup-0.2.0.exe", + downloadUrl: "http://updates.local/Scoreko-setup-0.2.0.exe", + }, + }, + { tempDirectory, allowInsecureHttp: false }, + ), + /unsupported protocol/, + ); +}); diff --git a/src/tests/update-settings.test.ts b/src/tests/update-settings.test.ts index 49f9eec..270602a 100644 --- a/src/tests/update-settings.test.ts +++ b/src/tests/update-settings.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import test from "node:test"; import { AppRuntimeConfig } from "../main/config/runtime-config"; -import { loadUpdateSettings, readUpdateFileConfig } from "../main/updates/update-settings"; +import { loadUpdateSettings, readUpdateFileConfig } from "../main/updates/update-config"; const baseConfig: AppRuntimeConfig = { title: "Scoreko", @@ -34,6 +34,18 @@ test("loadUpdateSettings keeps updates disabled when the runtime config disables assert.equal(settings.apiUrl, "https://gitea.local/releases/latest"); }); +test("loadUpdateSettings fails closed on insecure production update URLs", () => { + const rootPath = makeTempRoot({ + enabled: true, + apiUrl: "http://gitea.local/releases/latest", + }); + + const settings = loadUpdateSettings(baseConfig, rootPath, () => undefined, { allowInsecureHttp: false }); + + assert.equal(settings.enabled, false); + assert.equal(settings.apiUrl, undefined); +}); + test("loadUpdateSettings lets runtime config override file settings", () => { const rootPath = makeTempRoot({ enabled: true, diff --git a/src/tests/update-utils.test.ts b/src/tests/update-utils.test.ts index 308d560..313b869 100644 --- a/src/tests/update-utils.test.ts +++ b/src/tests/update-utils.test.ts @@ -4,9 +4,10 @@ import test from "node:test"; import { buildReleaseUpdate, isVersionNewer, + parseGiteaRelease, sanitizeFileName, selectInstallerAsset, -} from "../main/updates/update-utils"; +} from "../main/updates/update-schema"; test("isVersionNewer compares semantic versions with optional v prefix", () => { assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true); @@ -17,9 +18,10 @@ test("isVersionNewer compares semantic versions with optional v prefix", () => { test("selectInstallerAsset picks the first matching exe asset", () => { const asset = selectInstallerAsset( { + tagName: "v0.2.0", assets: [ - { name: "latest.yml", browser_download_url: "http://gitea/latest.yml" }, - { name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 }, + { name: "latest.yml", browserDownloadUrl: "http://gitea/latest.yml" }, + { name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 }, ], }, "Scoreko-setup-.*\\.exe$", @@ -35,8 +37,8 @@ test("selectInstallerAsset picks the first matching exe asset", () => { test("buildReleaseUpdate returns null when the release is not newer", () => { const update = buildReleaseUpdate( { - tag_name: "v0.1.0", - assets: [{ name: "Scoreko-setup-0.1.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.1.0.exe" }], + tagName: "v0.1.0", + assets: [{ name: "Scoreko-setup-0.1.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.1.0.exe" }], }, "0.1.0", "Scoreko-setup-.*\\.exe$", @@ -48,10 +50,10 @@ test("buildReleaseUpdate returns null when the release is not newer", () => { test("buildReleaseUpdate builds update info for newer releases", () => { const update = buildReleaseUpdate( { - tag_name: "v0.2.0", - name: "Scoreko 0.2.0", - html_url: "http://gitea/releases/v0.2.0", - assets: [{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe" }], + tagName: "v0.2.0", + title: "Scoreko 0.2.0", + pageUrl: "http://gitea/releases/v0.2.0", + assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }], }, "0.1.0", "Scoreko-setup-.*\\.exe$", @@ -66,3 +68,22 @@ test("buildReleaseUpdate builds update info for newer releases", () => { test("sanitizeFileName removes Windows-unsafe characters", () => { assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe"); }); + +test("parseGiteaRelease rejects malformed remote metadata", () => { + assert.equal(parseGiteaRelease({ name: "missing tag", assets: [] }), null); + assert.equal(parseGiteaRelease({ tag_name: "v0.2.0", assets: "wrong" }), null); +}); + +test("buildReleaseUpdate rejects insecure download URLs when policy forbids them", () => { + const update = buildReleaseUpdate( + { + tagName: "v0.2.0", + assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }], + }, + "0.1.0", + "Scoreko-setup-.*\\.exe$", + { allowInsecureHttp: false }, + ); + + assert.equal(update, null); +});