From 0b9218149f69e2b2ecf2f773beb5fa935e85c43c Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Wed, 15 Jul 2020 15:24:38 -0700 Subject: [PATCH] feat: validate browser dependencies before launching on Linux (#2960) Missing dependencies is #1 problem with launching on Linux. This patch starts validating browser dependencies before launching browser on Linux. In case of a missing dependency, we will abandon launching with an error that lists all missing libs. References #2745 --- src/install/browserPaths.ts | 14 +++++ src/server/browserType.ts | 8 +++ src/server/validateDependencies.ts | 88 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/server/validateDependencies.ts diff --git a/src/install/browserPaths.ts b/src/install/browserPaths.ts index c48073d643..233a137038 100644 --- a/src/install/browserPaths.ts +++ b/src/install/browserPaths.ts @@ -40,6 +40,20 @@ export const hostPlatform = ((): BrowserPlatform => { return platform as BrowserPlatform; })(); +export function linuxLddDirectories(browserPath: string, browser: BrowserDescriptor): string[] { + if (browser.name === 'chromium') + return [path.join(browserPath, 'chrome-linux')]; + if (browser.name === 'firefox') + return [path.join(browserPath, 'firefox')]; + if (browser.name === 'webkit') { + return [ + path.join(browserPath, 'minibrowser-gtk'), + path.join(browserPath, 'minibrowser-wpe'), + ]; + } + return []; +} + export function executablePath(browserPath: string, browser: BrowserDescriptor): string | undefined { let tokens: string[] | undefined; if (browser.name === 'chromium') { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 1c927fde55..b5bfe9d5e3 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -33,6 +33,7 @@ import * as types from '../types'; import { TimeoutSettings } from '../timeoutSettings'; import { WebSocketServer } from './webSocketServer'; import { LoggerSink } from '../loggerSink'; +import { validateDependencies } from './validateDependencies'; type FirefoxPrefsOptions = { firefoxUserPrefs?: { [key: string]: string | number | boolean } }; type LaunchOptions = types.LaunchOptions & { logger?: LoggerSink }; @@ -62,11 +63,13 @@ export abstract class BrowserTypeBase implements BrowserType { private _name: string; private _executablePath: string | undefined; private _webSocketNotPipe: WebSocketNotPipe | null; + private _browserDescriptor: browserPaths.BrowserDescriptor; readonly _browserPath: string; constructor(packagePath: string, browser: browserPaths.BrowserDescriptor, webSocketOrPipe: WebSocketNotPipe | null) { this._name = browser.name; const browsersPath = browserPaths.browsersPath(packagePath); + this._browserDescriptor = browser; this._browserPath = browserPaths.browserDirectory(browsersPath, browser); this._executablePath = browserPaths.executablePath(this._browserPath, browser); this._webSocketNotPipe = webSocketOrPipe; @@ -186,6 +189,11 @@ export abstract class BrowserTypeBase implements BrowserType { if (!executable) throw new Error(`No executable path is specified. Pass "executablePath" option directly.`); + if (!executablePath) { + // We can only validate dependencies for bundled browsers. + await validateDependencies(this._browserPath, this._browserDescriptor); + } + // Note: it is important to define these variables before launchProcess, so that we don't get // "Cannot access 'browserServer' before initialization" if something went wrong. let transport: ConnectionTransport | undefined = undefined; diff --git a/src/server/validateDependencies.ts b/src/server/validateDependencies.ts new file mode 100644 index 0000000000..38c26c2160 --- /dev/null +++ b/src/server/validateDependencies.ts @@ -0,0 +1,88 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import * as util from 'util'; +import * as path from 'path'; +import * as os from 'os'; +import {spawn} from 'child_process'; +import {linuxLddDirectories, BrowserDescriptor} from '../install/browserPaths.js'; + +const accessAsync = util.promisify(fs.access.bind(fs)); +const checkExecutable = (filePath: string) => accessAsync(filePath, fs.constants.X_OK).then(() => true).catch(e => false); +const statAsync = util.promisify(fs.stat.bind(fs)); +const readdirAsync = util.promisify(fs.readdir.bind(fs)); + +export async function validateDependencies(browserPath: string, browser: BrowserDescriptor) { + // We currently only support Linux. + if (os.platform() !== 'linux') + return; + const directoryPaths = linuxLddDirectories(browserPath, browser); + const lddPaths: string[] = []; + for (const directoryPath of directoryPaths) + lddPaths.push(...(await executablesOrSharedLibraries(directoryPath))); + const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependencies(lddPath))); + const missingDeps = new Set(); + for (const deps of allMissingDeps) { + for (const dep of deps) + missingDeps.add(dep); + } + if (!missingDeps.size) + return; + const deps = [...missingDeps].sort().map(dep => ' ' + dep).join('\n'); + throw new Error('Host system is missing the following dependencies to run browser\n' + deps); +} + +async function executablesOrSharedLibraries(directoryPath: string): Promise { + const allPaths = (await readdirAsync(directoryPath)).map(file => path.resolve(directoryPath, file)); + const allStats = await Promise.all(allPaths.map(aPath => statAsync(aPath))); + const filePaths = allPaths.filter((aPath, index) => allStats[index].isFile()); + + const executablersOrLibraries = (await Promise.all(filePaths.map(async filePath => { + const basename = path.basename(filePath).toLowerCase(); + if (basename.endsWith('.so') || basename.includes('.so.')) + return filePath; + if (await checkExecutable(filePath)) + return filePath; + return false; + }))).filter(Boolean); + + return executablersOrLibraries as string[]; +} + +async function missingFileDependencies(filePath: string): Promise> { + const {stdout} = await lddAsync(filePath); + const missingDeps = stdout.split('\n').map(line => line.trim()).filter(line => line.endsWith('not found') && line.includes('=>')).map(line => line.split('=>')[0].trim()); + return missingDeps; +} + +function lddAsync(filePath: string): Promise<{stdout: string, stderr: string, code: number}> { + const dirname = path.dirname(filePath); + const ldd = spawn('ldd', [filePath], { + cwd: dirname, + env: { + ...process.env, + LD_LIBRARY_PATH: dirname, + }, + }); + + return new Promise(resolve => { + let stdout = ''; + let stderr = ''; + ldd.stdout.on('data', data => stdout += data); + ldd.stderr.on('data', data => stderr += data); + ldd.on('close', code => resolve({stdout, stderr, code})); + }); +}