| // Copyright (c) 2017, Compiler Explorer Authors |
| // All rights reserved. |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are met: |
| // |
| // * Redistributions of source code must retain the above copyright notice, |
| // this list of conditions and the following disclaimer. |
| // * Redistributions in binary form must reproduce the above copyright |
| // notice, this list of conditions and the following disclaimer in the |
| // documentation and/or other materials provided with the distribution. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
| // ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE |
| // LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
| // POSSIBILITY OF SUCH DAMAGE. |
| |
| import child_process from 'child_process'; |
| import path from 'path'; |
| |
| import fs from 'fs-extra'; |
| import Graceful from 'node-graceful'; |
| import treeKill from 'tree-kill'; |
| import _ from 'underscore'; |
| |
| import {logger} from './logger'; |
| import {propsFor} from './properties'; |
| |
| const execProps = propsFor('execution'); |
| |
| function setupOnError(stream, name) { |
| if (stream === undefined) return; |
| stream.on('error', err => { |
| logger.error(`Error with ${name} stream:`, err); |
| }); |
| } |
| |
| export function executeDirect(command, args, options, filenameTransform) { |
| // filename transform is expected to have been pre-applied by the caller. |
| // it is passed through here only so clients can see it in the result. |
| filenameTransform = filenameTransform || (x => x); |
| options = options || {}; |
| const maxOutput = options.maxOutput || 1024 * 1024; |
| const timeoutMs = options.timeoutMs || 0; |
| const env = {...process.env, ...options.env}; |
| |
| if (options.ldPath) { |
| env.LD_LIBRARY_PATH = options.ldPath; |
| } |
| |
| if (options.wrapper) { |
| args = args.slice(0); // prevent mutating the caller's arguments |
| args.unshift(command); |
| command = options.wrapper; |
| |
| if (command.startsWith('./')) command = path.join(process.cwd(), command); |
| } |
| |
| let okToCache = true; |
| const cwd = options.customCwd |
| ? options.customCwd |
| : command.startsWith('/mnt') && process.env.wsl |
| ? process.env.winTmp |
| : process.env.tmpDir; |
| logger.debug('Execution', {type: 'executing', command: command, args: args, env: env, cwd: cwd}); |
| const startTime = process.hrtime.bigint(); |
| |
| // AP: Run Windows-volume executables in winTmp. Otherwise, run in tmpDir (which may be undefined). |
| // https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options |
| const child = child_process.spawn(command, args, { |
| cwd: cwd, |
| env: env, |
| detached: process.platform === 'linux', |
| }); |
| let running = true; |
| |
| const kill = |
| options.killChild || |
| (() => { |
| if (running) treeKill(child.pid); |
| }); |
| |
| const streams = { |
| stderr: '', |
| stdout: '', |
| truncated: false, |
| }; |
| let timeout; |
| if (timeoutMs) |
| timeout = setTimeout(() => { |
| logger.warn(`Timeout for ${command} ${args} after ${timeoutMs}ms`); |
| okToCache = false; |
| kill(); |
| streams.stderr += '\nKilled - processing time exceeded'; |
| }, timeoutMs); |
| |
| function setupStream(stream, name) { |
| if (stream === undefined) return; |
| stream.on('data', data => { |
| if (streams.truncated) return; |
| const newLength = streams[name].length + data.length; |
| if (maxOutput > 0 && newLength > maxOutput) { |
| streams[name] = streams[name] + data.slice(0, maxOutput - streams[name].length); |
| streams[name] += '\n[Truncated]'; |
| streams.truncated = true; |
| kill(); |
| return; |
| } |
| streams[name] += data; |
| }); |
| setupOnError(stream, name); |
| } |
| |
| setupOnError(child.stdin, 'stdin'); |
| setupStream(child.stdout, 'stdout'); |
| setupStream(child.stderr, 'stderr'); |
| child.on('exit', code => { |
| logger.debug('Execution', {type: 'exited', code: code}); |
| if (timeout !== undefined) clearTimeout(timeout); |
| running = false; |
| }); |
| return new Promise((resolve, reject) => { |
| child.on('error', e => { |
| logger.debug(`Execution error with ${command} args: ${args}:`, e); |
| reject(e); |
| }); |
| child.on('close', code => { |
| // Being killed externally gives a NULL error code. Synthesize something different here. |
| if (code === null) code = -1; |
| if (timeout !== undefined) clearTimeout(timeout); |
| const endTime = process.hrtime.bigint(); |
| const result = { |
| code, |
| okToCache, |
| filenameTransform, |
| stdout: streams.stdout, |
| stderr: streams.stderr, |
| execTime: ((endTime - startTime) / BigInt(1000000)).toString(), |
| }; |
| logger.debug('Execution', {type: 'executed', command: command, args: args, result: result}); |
| resolve(result); |
| }); |
| if (child.stdin) { |
| if (options.input) child.stdin.write(options.input); |
| child.stdin.end(); |
| } |
| }); |
| } |
| |
| export function getNsJailCfgFilePath(configName) { |
| const propKey = `nsjail.config.${configName}`; |
| const configPath = execProps(propKey); |
| if (configPath === undefined) { |
| logger.error(`Could not find ${propKey}. Are you missing a definition?`); |
| throw new Error(`Missing nsjail execution config property key ${propKey}`); |
| } |
| return configPath; |
| } |
| |
| export function getFirejailProfileFilePath(profileName) { |
| const propKey = `firejail.profile.${profileName}`; |
| const profilePath = execProps(propKey); |
| if (profilePath === undefined) { |
| logger.error(`Could not find ${propKey}. Are you missing a definition?`); |
| throw new Error(`Missing firejail execution profile property key ${propKey}`); |
| } |
| return profilePath; |
| } |
| |
| export function getNsJailOptions(configName, command, args, options) { |
| options = {...options}; |
| const jailingOptions = ['--config', getNsJailCfgFilePath(configName)]; |
| |
| if (options.timeoutMs) { |
| const ExtraWallClockLeewayMs = 1000; |
| jailingOptions.push(`--time_limit=${Math.round((options.timeoutMs + ExtraWallClockLeewayMs) / 1000)}`); |
| } |
| |
| const homeDir = '/app'; |
| let filenameTransform; |
| if (options.customCwd) { |
| let replacement = options.customCwd; |
| if (options.appHome) { |
| replacement = options.appHome; |
| const relativeCwd = path.join(homeDir, path.relative(options.appHome, options.customCwd)); |
| jailingOptions.push('--cwd', relativeCwd, '--bindmount', `${options.appHome}:${homeDir}`); |
| } else { |
| jailingOptions.push('--cwd', homeDir, '--bindmount', `${options.customCwd}:${homeDir}`); |
| } |
| |
| filenameTransform = opt => opt.replace(replacement, '/app'); |
| args = args.map(filenameTransform); |
| delete options.customCwd; |
| } |
| |
| const env = {...options.env, HOME: homeDir}; |
| if (options.ldPath) { |
| jailingOptions.push(`--env=LD_LIBRARY_PATH=${options.ldPath}`); |
| delete options.ldPath; |
| delete env.LD_LIBRARY_PATH; |
| } |
| |
| for (const [key, value] of Object.entries(env)) { |
| if (value !== undefined) jailingOptions.push(`--env=${key}=${value}`); |
| } |
| delete options.env; |
| |
| return { |
| args: jailingOptions.concat(['--', command]).concat(args), |
| options, |
| filenameTransform, |
| }; |
| } |
| |
| export function getSandboxNsjailOptions(command, args, options) { |
| // If we already had a custom cwd, use that. |
| if (options.customCwd) { |
| let relativeCommand = command; |
| if (command.startsWith(options.customCwd)) { |
| relativeCommand = path.relative(options.customCwd, command); |
| if (path.dirname(relativeCommand) === '.') { |
| relativeCommand = `./${relativeCommand}`; |
| } |
| } |
| return getNsJailOptions('sandbox', relativeCommand, args, options); |
| } |
| |
| // Else, assume the executable should be run as `./exec` and run it from its directory. |
| options = {...options, customCwd: path.dirname(command)}; |
| return getNsJailOptions('sandbox', `./${path.basename(command)}`, args, options); |
| } |
| |
| function sandboxNsjail(command, args, options) { |
| logger.info('Sandbox execution via nsjail', {command, args}); |
| const nsOpts = getSandboxNsjailOptions(command, args, options); |
| return executeDirect(execProps('nsjail'), nsOpts.args, nsOpts.options, nsOpts.filenameTransform); |
| } |
| |
| function executeNsjail(command, args, options) { |
| const nsOpts = getNsJailOptions('execute', command, args, options); |
| return executeDirect(execProps('nsjail'), nsOpts.args, nsOpts.options, nsOpts.filenameTransform); |
| } |
| |
| function withFirejailTimeout(args, options) { |
| if (options && options.timeoutMs) { |
| // const ExtraWallClockLeewayMs = 1000; |
| const ExtraCpuLeewayMs = 1500; |
| return args.concat([`--rlimit-cpu=${Math.round((options.timeoutMs + ExtraCpuLeewayMs) / 1000)}`]); |
| } |
| return args; |
| } |
| |
| function sandboxFirejail(command, args, options) { |
| logger.info('Sandbox execution via firejail', {command, args}); |
| const execPath = path.dirname(command); |
| const execName = path.basename(command); |
| const jailingOptions = withFirejailTimeout([ |
| '--quiet', |
| '--deterministic-exit-code', |
| '--terminate-orphans', |
| '--profile=' + getFirejailProfileFilePath('sandbox'), |
| `--private=${execPath}`, |
| '--private-cwd', |
| ]); |
| |
| if (options.ldPath) { |
| jailingOptions.push(`--env=LD_LIBRARY_PATH=${options.ldPath}`); |
| delete options.ldPath; |
| } |
| |
| for (const key of Object.keys(options.env || {})) { |
| jailingOptions.push(`--env=${key}=${options.env[key]}`); |
| } |
| delete options.env; |
| |
| return executeDirect(execProps('firejail'), jailingOptions.concat([`./${execName}`]).concat(args), options); |
| } |
| |
| const sandboxDispatchTable = { |
| none: (command, args, options) => { |
| logger.info('Sandbox execution (sandbox disabled)', {command, args}); |
| return executeDirect(command, args, options); |
| }, |
| nsjail: sandboxNsjail, |
| firejail: sandboxFirejail, |
| }; |
| |
| export async function sandbox(command, args, options) { |
| const type = execProps('sandboxType', 'firejail'); |
| const dispatchEntry = sandboxDispatchTable[type]; |
| if (!dispatchEntry) throw new Error(`Bad sandbox type ${type}`); |
| return dispatchEntry(command, args, options); |
| } |
| |
| const wineSandboxName = 'ce-wineserver'; |
| // WINE takes a while to initialise and very often we don't need to run it at |
| // all during startup. So, we do just the bare minimum at startup and then make |
| // a promise that all subsequent WINE calls wait on. |
| let wineInitPromise = null; |
| |
| export function startWineInit() { |
| const wine = execProps('wine'); |
| if (!wine) { |
| logger.info('WINE not configured'); |
| return; |
| } |
| |
| const server = execProps('wineServer'); |
| const executionType = execProps('executionType', 'none'); |
| // We need to fire up a firejail wine server even in nsjail world (for now). |
| const firejail = executionType === 'firejail' || executionType === 'nsjail' ? execProps('firejail') : null; |
| const env = applyWineEnv({PATH: process.env.PATH}); |
| const prefix = env.WINEPREFIX; |
| |
| logger.info(`Initialising WINE in ${prefix}`); |
| |
| const asyncSetup = async () => { |
| if (!(await fs.pathExists(prefix))) { |
| logger.info(`Creating directory ${prefix}`); |
| await fs.mkdir(prefix); |
| } |
| |
| logger.info(`Killing any pre-existing wine-server`); |
| let result = await child_process.exec(`${server} -k || true`, {env: env}); |
| logger.info(`Result: ${result}`); |
| logger.info(`Waiting for any pre-existing server to stop...`); |
| result = await child_process.exec(`${server} -w`, {env: env}); |
| logger.info(`Result: ${result}`); |
| |
| // We run a long-lived cmd process, to: |
| // * test that WINE works |
| // * be something which holds open a working firejail sandbox |
| // All future WINE compiles go through the same sandbox. |
| // We wait until the process has printed out some known good text, but don't wait |
| // for it to exit (it won't, on purpose). |
| |
| let wineServer; |
| if (firejail) { |
| logger.info(`Starting a new, firejailed, long-lived wineserver complex`); |
| wineServer = child_process.spawn( |
| firejail, |
| [ |
| '--quiet', |
| '--profile=' + getFirejailProfileFilePath('wine'), |
| '--private', |
| `--name=${wineSandboxName}`, |
| wine, |
| 'cmd', |
| ], |
| {env: env, detached: true}, |
| ); |
| logger.info(`firejailed pid=${wineServer.pid}`); |
| } else { |
| logger.info(`Starting a new, long-lived wineserver complex ${server}`); |
| wineServer = child_process.spawn(wine, ['cmd'], {env: env, detached: true}); |
| logger.info(`wineserver pid=${wineServer.pid}`); |
| } |
| |
| wineServer.on('close', code => { |
| logger.info(`WINE server complex exited with code ${code}`); |
| }); |
| |
| Graceful.on('exit', () => { |
| const waitingPromises = []; |
| |
| function waitForExit(process, name) { |
| return new Promise(resolve => { |
| process.on('close', () => { |
| logger.info(`Process '${name}' closed`); |
| resolve(); |
| }); |
| }); |
| } |
| |
| if (wineServer && !wineServer.killed) { |
| logger.info('Shutting down WINE server complex'); |
| wineServer.kill(); |
| if (wineServer.killed) { |
| waitingPromises.push(waitForExit(wineServer, 'WINE server')); |
| } |
| wineServer = null; |
| } |
| return Promise.all(waitingPromises); |
| }); |
| |
| return new Promise((resolve, reject) => { |
| setupOnError(wineServer.stdin, 'stdin'); |
| setupOnError(wineServer.stdout, 'stdout'); |
| setupOnError(wineServer.stderr, 'stderr'); |
| const magicString = '!!EVERYTHING IS WORKING!!'; |
| wineServer.stdin.write(`echo ${magicString}`); |
| |
| let output = ''; |
| wineServer.stdout.on('data', data => { |
| logger.info(`Output from wine server complex: ${data.toString().trim()}`); |
| output += data; |
| if (output.includes(magicString)) { |
| resolve(); |
| } |
| }); |
| wineServer.stderr.on('data', data => |
| logger.info(`stderr output from wine server complex: ${data.toString().trim()}`), |
| ); |
| wineServer.on('error', e => { |
| logger.error(`WINE server complex exited with error ${e}`); |
| reject(e); |
| }); |
| wineServer.on('close', code => { |
| logger.info(`WINE server complex exited with code ${code}`); |
| reject(); |
| }); |
| }); |
| }; |
| wineInitPromise = asyncSetup(); |
| } |
| |
| function applyWineEnv(env) { |
| return { |
| ...env, |
| // Force use of wine vcruntime (See change 45106c382) |
| WINEDLLOVERRIDES: 'vcruntime140=b', |
| WINEDEBUG: '-all', |
| WINEPREFIX: execProps('winePrefix'), |
| }; |
| } |
| |
| function needsWine(command) { |
| return command.match(/\.exe$/i) && process.platform === 'linux' && !process.env.wsl; |
| } |
| |
| async function executeWineDirect(command, args, options) { |
| options = _.clone(options) || {}; |
| options.env = applyWineEnv(options.env); |
| args = [command, ...args]; |
| await wineInitPromise; |
| return executeDirect(execProps('wine'), args, options); |
| } |
| |
| async function executeFirejail(command, args, options) { |
| options = _.clone(options) || {}; |
| const firejail = execProps('firejail'); |
| const baseOptions = withFirejailTimeout(['--quiet', '--deterministic-exit-code', '--terminate-orphans'], options); |
| if (needsWine(command)) { |
| logger.debug('WINE execution via firejail', {command, args}); |
| options.env = applyWineEnv(options.env); |
| args = [command, ...args]; |
| command = execProps('wine'); |
| baseOptions.push('--profile=' + getFirejailProfileFilePath('wine'), `--join=${wineSandboxName}`); |
| delete options.customCwd; |
| baseOptions.push(command); |
| await wineInitPromise; |
| return executeDirect(firejail, baseOptions.concat(args), options); |
| } |
| |
| logger.debug('Regular execution via firejail', {command, args}); |
| baseOptions.push('--profile=' + getFirejailProfileFilePath('execute')); |
| |
| if (options.ldPath) { |
| baseOptions.push(`--env=LD_LIBRARY_PATH=${options.ldPath}`); |
| delete options.ldPath; |
| } |
| |
| let filenameTransform; |
| if (options.customCwd) { |
| baseOptions.push(`--private=${options.customCwd}`); |
| const replacement = options.customCwd; |
| filenameTransform = opt => opt.replace(replacement, '.'); |
| args = args.map(filenameTransform); |
| delete options.customCwd; |
| // TODO: once it's supported properly in our patched firejail, make this option common to both customCwd and |
| // non-customCwd code paths. |
| baseOptions.push('--private-cwd'); |
| } else { |
| baseOptions.push('--private'); |
| } |
| baseOptions.push(command); |
| return executeDirect(firejail, baseOptions.concat(args), options, filenameTransform); |
| } |
| |
| async function executeNone(command, args, options) { |
| if (needsWine(command)) { |
| return executeWineDirect(command, args, options); |
| } |
| return executeDirect(command, args, options); |
| } |
| |
| const executeDispatchTable = { |
| none: executeNone, |
| firejail: executeFirejail, |
| nsjail: (command, args, options) => |
| needsWine(command) ? executeFirejail(command, args, options) : executeNsjail(command, args, options), |
| }; |
| |
| export async function execute(command, args, options) { |
| const type = execProps('executionType', 'none'); |
| const dispatchEntry = executeDispatchTable[type]; |
| if (!dispatchEntry) throw new Error(`Bad sandbox type ${type}`); |
| return dispatchEntry(command, args, options); |
| } |