From 56f6c6fa4c611e3fee0833742def438a04cefffb Mon Sep 17 00:00:00 2001 From: throwaway96 <68320646+throwaway96@users.noreply.github.com> Date: Mon, 11 Dec 2023 01:36:36 -0500 Subject: [PATCH] service: make TypeScript strict --- package-lock.json | 18 ++++-- package.json | 1 + services/adapter.ts | 1 + services/elevate-service.ts | 4 +- services/protocol.ts | 12 ++-- services/service.ts | 114 ++++++++++++++++++++++-------------- tsconfig.json | 16 ++++- 7 files changed, 106 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45c0b4d..f0e855a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@babel/preset-env": "^7.23.5", "@babel/preset-typescript": "^7.23.3", "@types/bluebird": "^3.5.42", + "@types/node": "^20.10.4", "@types/node-fetch": "^2.6.9", "@types/progress-stream": "^2.0.5", "@types/webos-service": "^0.4.6", @@ -2091,10 +2092,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", - "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==", - "dev": true + "version": "20.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", + "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/node-fetch": { "version": "2.6.9", @@ -17179,6 +17183,12 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json index c14905c..f77b5c8 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@babel/preset-env": "^7.23.5", "@babel/preset-typescript": "^7.23.3", "@types/bluebird": "^3.5.42", + "@types/node": "^20.10.4", "@types/node-fetch": "^2.6.9", "@types/progress-stream": "^2.0.5", "@types/webos-service": "^0.4.6", diff --git a/services/adapter.ts b/services/adapter.ts index 10d9bd9..bae18c7 100644 --- a/services/adapter.ts +++ b/services/adapter.ts @@ -24,6 +24,7 @@ fetch.Promise = Bluebird.Promise; // Sadly these need to be manually typed according to // https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node // since types infered from Bluebird.Promise.promisify are wrong. +// @ts-ignore export const asyncPipeline: ( ...args: ReadonlyArray ) => Promise = Bluebird.Promise.promisify(pipeline); diff --git a/services/elevate-service.ts b/services/elevate-service.ts index 9bbfff6..613d38a 100755 --- a/services/elevate-service.ts +++ b/services/elevate-service.ts @@ -4,7 +4,7 @@ import { existsSync, statSync, readFileSync, writeFileSync } from 'fs'; import { execFile } from 'child_process'; import { dirname, resolve } from 'path'; -process.env.PATH = `/usr/sbin:${process.env.PATH}`; +process.env['PATH'] = `/usr/sbin:${process.env['PATH']}`; function isFile(path: string): boolean { try { @@ -40,7 +40,7 @@ function patchServiceFile(serviceFile: string): boolean { serviceFileNew = serviceFileNew.replace(/^Exec=\/usr\/bin\/run-js-service/gm, `Exec=${runJsServicePath}`); } else if (serviceFileNew.indexOf('/jailer') !== -1) { console.info(`[ ] ${serviceFile} is a native service`); - serviceFileNew = serviceFileNew.replace(/^Exec=\/usr\/bin\/jailer .* ([^ ]*)$/gm, (match, binaryPath) => `Exec=${binaryPath}`); + serviceFileNew = serviceFileNew.replace(/^Exec=\/usr\/bin\/jailer .* ([^ ]*)$/gm, (_, binaryPath) => `Exec=${binaryPath}`); } else if (serviceFileNew.indexOf('Exec=/media') === -1) { // Ignore elevated native services... console.info(`[~] ${serviceFile}: unknown service type, this may cause some troubles`); diff --git a/services/protocol.ts b/services/protocol.ts index 6c64fce..9fee60a 100644 --- a/services/protocol.ts +++ b/services/protocol.ts @@ -1,16 +1,16 @@ -type Response> = T & { +interface Response { returnValue: true; -}; +} -type ErrorResponse> = T & { +interface ErrorResponse { returnValue: false; errorText: string; -}; +} -export function makeSuccess(payload: T): Response { +export function makeSuccess(payload: Record): Response { return { returnValue: true, ...payload }; } -export function makeError(error: string, payload?: T): ErrorResponse { +export function makeError(error: string, payload?: Record): ErrorResponse { return { returnValue: false, errorText: error, ...payload }; } diff --git a/services/service.ts b/services/service.ts index 7de6e03..399234e 100644 --- a/services/service.ts +++ b/services/service.ts @@ -40,22 +40,33 @@ const homebrewBaseDir = ((): string | null => { })(); // Maps internal setting field name with filesystem flag name. -type FlagName = string; -const availableFlags = { +// TODO: Figure out how to avoid repeating these. +type FlagName = 'telnetDisabled' | 'failsafe' | 'sshdEnabled' | 'blockUpdates'; +const availableFlags: Record = { telnetDisabled: 'webosbrew_telnet_disabled', failsafe: 'webosbrew_failsafe', sshdEnabled: 'webosbrew_sshd_enabled', blockUpdates: 'webosbrew_block_updates', -} as Record; +} as const; +type FlagFileName = (typeof availableFlags)[FlagName]; -function runningAsRoot(): boolean { +const runningAsRoot: boolean = (() => { + if (typeof process.getuid === 'undefined') { + throw new Error('process.getuid() is missing'); + } return process.getuid() === 0; +})(); + +function assertNodeError(error: Error | unknown): asserts error is NodeJS.ErrnoException { + if (!(error instanceof Error)) { + throw error; + } } function asyncCall>(srv: Service, uri: string, args: Record): Promise { return new Promise((resolve, reject) => { srv.call(uri, args, ({ payload }) => { - if (payload.returnValue) { + if (payload['returnValue']) { resolve(payload as T); } else { reject(payload); @@ -79,7 +90,7 @@ function createToast(message: string, service: Service, extras: Record { try { return (await asyncStat(targetPath)).isFile(); - } catch (err) { + } catch (err: unknown) { return false; } } @@ -120,7 +131,7 @@ function hashString(data: string, algorithm: string): string { * Elevates a package by name. */ async function elevateService(pkg: string): Promise { - if (runningAsRoot()) { + if (runningAsRoot) { console.info('Elevating service...'); await asyncExecFile(path.join(__dirname, 'elevate-service'), [pkg]); } else { @@ -131,33 +142,34 @@ async function elevateService(pkg: string): Promise { /** * Returns the file path for a flag. */ -function flagPath(flag: FlagName): string { - return `/var/luna/preferences/${flag}`; +function flagFilePath(flagFile: FlagFileName): string { + return `/var/luna/preferences/${flagFile}`; } /** * Returns whether a flag is set or not. */ -async function flagRead(flag: FlagName): Promise { - return asyncExists(flagPath(flag)); +async function flagFileRead(flagFile: FlagFileName): Promise { + return asyncExists(flagFilePath(flagFile)); } /** * Sets the value of a flag. */ -async function flagSet(flag: FlagName, enabled: boolean): Promise { +async function flagFileSet(flagFile: FlagFileName, enabled: boolean): Promise { if (enabled) { // The file content is ignored, file presence is what matters. Writing '1' acts as a hint. - await asyncWriteFile(flagPath(flag), '1'); + await asyncWriteFile(flagFilePath(flagFile), '1'); } else { try { - await asyncUnlink(flagPath(flag)); - } catch (err) { + await asyncUnlink(flagFilePath(flagFile)); + } catch (err: unknown) { + assertNodeError(err); // Already deleted is not a fatal error. if (err.code !== 'ENOENT') throw err; } } - return flagRead(flag); + return flagFileRead(flagFile); } /** @@ -172,7 +184,7 @@ async function packageInfo(filePath: string): Promise> { .filter((m) => m.length) .map((p) => [p.slice(0, p.indexOf(': ')), p.slice(p.indexOf(': ') + 2)]), ); - if (!resp.Package) { + if (!resp['Package']) { throw new Error(`Invalid package info: ${JSON.stringify(resp)}`); } return resp; @@ -310,7 +322,8 @@ function tryRespond>(runner: (message: Message) => try { const reply: T = await runner(message); message.respond(makeSuccess(reply)); - } catch (err) { + } catch (err: unknown) { + assertNodeError(err); message.respond(makeError(err.message)); } finally { message.cancel({}); @@ -318,12 +331,12 @@ function tryRespond>(runner: (message: Message) => }; } -function runService() { - const service = new Service(serviceInfo.id, null, { idleTimer: 30 }); +function runService(): void { + const service = new Service(serviceInfo.id, undefined, { idleTimer: 30 }); const serviceRemote = new ServiceRemote(); function getInstallerService(): Service { - if (runningAsRoot()) { + if (runningAsRoot) { return service; } return serviceRemote as Service; @@ -357,7 +370,7 @@ function runService() { throw new Error(res.statusText); } const progressReporter = progress({ - length: parseInt(res.headers.get('content-length'), 10), + length: parseInt(res.headers.get('content-length') ?? '0', 10), time: 300 /* ms */, }); progressReporter.on('progress', (p) => { @@ -373,11 +386,12 @@ function runService() { throw new Error(`Invalid file checksum (${payload.ipkHash} expected, got ${checksum}`); } - let pkginfo: Record = { Package: payload.id }; + let pkginfo: Record = { Package: payload.id }; try { pkginfo = await packageInfo(targetPath); - } catch (err) { + } catch (err: unknown) { + assertNodeError(err); await createToast(`Package info fetch failed: ${err.message}`, service); } @@ -394,7 +408,7 @@ function runService() { // If reelevation fails for some reason the service should still be // reelevated on reboot on devices with persistent autostart hooks (since // we launch elevate-service in startup.sh script) - if (runningAsRoot() && pkginfo && pkginfo.Package === kHomebrewChannelPackageId) { + if (runningAsRoot && pkginfo && pkginfo['Package'] === kHomebrewChannelPackageId) { message.respond({ statusText: 'Self-update…' }); await createToast('Performing self-update...', service); @@ -409,8 +423,8 @@ function runService() { try { const appInfo = await getAppInfo(installedPackageId); - await createToast(`Application installed: ${appInfo.title}`, service); - } catch (err) { + await createToast(`Application installed: ${appInfo['title']}`, service); + } catch (err: unknown) { console.warn('appinfo fetch failed:', err); await createToast(`Application installed: ${installedPackageId}`, service); } @@ -425,10 +439,12 @@ function runService() { /** * Removes existing package. */ + type UninstallPayload = { id: string }; service.register( 'uninstall', tryRespond(async (message: Message) => { - await removePackage(message.payload.id, getInstallerService()); + const payload = message.payload as UninstallPayload; + await removePackage(payload.id, getInstallerService()); return { statusText: 'Finished.' }; }), ); @@ -440,11 +456,11 @@ function runService() { 'getConfiguration', tryRespond(async () => { const futureFlags = Object.entries(availableFlags).map( - async ([field, flagName]) => [field, await flagRead(flagName)] as [string, boolean], + async ([flag, flagFile]) => [flag, await flagFileRead(flagFile)] as [FlagName, boolean], ); const flags = Object.fromEntries(await Promise.all(futureFlags)); return { - root: process.getuid() === 0, + root: runningAsRoot, homebrewBaseDir, ...flags, }; @@ -457,12 +473,13 @@ function runService() { type SetConfigurationPayload = Record; service.register( 'setConfiguration', - tryRespond(async (message) => { + tryRespond(async (message: Message) => { const payload = message.payload as SetConfigurationPayload; + // TODO: Use destructuring again once it works with type predicates. + // See https://github.com/microsoft/TypeScript/issues/41173 const futureFlagSets = Object.entries(payload) - .map(([field, value]) => [field, availableFlags[field], value] as [string, FlagName | undefined, boolean]) - .filter(([, flagName]) => flagName !== undefined) - .map(async ([field, flagName, value]) => [field, await flagSet(flagName, value)]); + .filter((pair: [string, boolean]): pair is [FlagName, boolean] => pair[0] in availableFlags) + .map(async ([flagName, value]) => [flagName, await flagFileSet(availableFlags[flagName], value)]); return Object.fromEntries(await Promise.all(futureFlagSets)); }), ); @@ -482,7 +499,7 @@ function runService() { */ service.register( 'checkRoot', - tryRespond(async () => ({ returnValue: runningAsRoot() })), + tryRespond(async () => ({ returnValue: runningAsRoot })), ); /** @@ -491,7 +508,7 @@ function runService() { service.register( 'updateStartupScript', tryRespond(async () => { - if (!runningAsRoot()) { + if (!runningAsRoot) { return { returnValue: true, statusText: 'Not running as root.' }; } @@ -562,7 +579,8 @@ function runService() { messages.push(`${startDevmode} has been manually modified!`); } } - } catch (err) { + } catch (err: unknown) { + assertNodeError(err); console.log(`Startup script update failed: ${err.stack}`); messages = ['Startup script update failed!', ...messages, `Error: ${err.toString()}`]; await createToast(messages.join('
'), service); @@ -585,7 +603,7 @@ function runService() { type GetAppInfoPayload = { id: string }; service.register( 'getAppInfo', - tryRespond(async (message) => { + tryRespond(async (message: Message) => { const payload = message.payload as GetAppInfoPayload; const appId: string = payload.id; if (!appId) throw new Error('missing `id` string field'); @@ -647,7 +665,7 @@ function runService() { type GetDrmStatusPayload = { appId: string }; service.register( 'getDrmStatus', - tryRespond(async (message) => ({ + tryRespond(async (message: Message) => ({ appId: (message.payload as GetDrmStatusPayload).appId, drmType: 'NCG DRM', installBasePath: '/media/cryptofs', @@ -663,8 +681,8 @@ function runService() { service.register( 'autostart', - tryRespond(async (message) => { - if (!runningAsRoot()) { + tryRespond(async (message: Message) => { + if (!runningAsRoot) { return { message: 'Not running as root.', returnValue: true }; } if (await asyncExists('/tmp/webosbrew_startup')) { @@ -694,7 +712,7 @@ function runService() { }); // Register activity if autostart was triggered in traditional way - if (message.payload?.reason !== 'activity') { + if (message.payload['reason'] !== 'activity') { await registerActivity(service); } @@ -711,14 +729,20 @@ if (process.argv[2] === 'self-update') { (async () => { const service = new ServiceRemote() as Service; try { + const packagePath = process.argv[3]; + if (typeof packagePath !== 'string') { + throw new Error('missing package path'); + } + await createToast('Performing self-update (inner)', service); - const installedPackageId = await installPackage(process.argv[3], service); + const installedPackageId = await installPackage(packagePath, service); await createToast('Elevating...', service); await elevateService(`${installedPackageId}.service`); await createToast('Self-update finished!', service); process.exit(0); - } catch (err) { - console.info(err); + } catch (err: unknown) { + console.error(err); + assertNodeError(err); await createToast(`Self-update failed: ${err.message}`, service); process.exit(1); } diff --git a/tsconfig.json b/tsconfig.json index 3465346..fa93e26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,13 +5,23 @@ "target": "es5", "allowJs": true, "lib": [ - "es2019" + "es5" ], - "moduleResolution": "node", + "moduleResolution": "node10", "allowSyntheticDefaultImports": true, - "noImplicitAny": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, "resolveJsonModule": true, + "types": [ + "node" + ], "esModuleInterop": true }, "exclude": [