'use strict'; const assert = require('node:assert'), BoxError = require('./boxerror.js'), child_process = require('node:child_process'), debug = require('debug')('box:shell'), path = require('node:path'), safe = require('safetydance'), _ = require('./underscore.js'); exports = module.exports = shell; function shell(tag) { assert.strictEqual(typeof tag, 'string'); return { bash: bash.bind(null, tag), spawn: spawn.bind(null, tag), sudo: sudo.bind(null, tag), }; } const SUDO = '/usr/bin/sudo'; const KILL_CHILD_CMD = path.join(__dirname, 'scripts/kill-child.sh'); function lineCount(buffer) { assert(Buffer.isBuffer(buffer)); const NEW_LINE = Buffer.from('\n'); let index = buffer.indexOf(NEW_LINE); let count = 0; while (index >= 0) { index = buffer.indexOf(NEW_LINE, index+1); ++count; } return count; } function spawn(tag, file, args, options) { assert.strictEqual(typeof tag, 'string'); assert.strictEqual(typeof file, 'string'); assert(Array.isArray(args)); assert.strictEqual(typeof options, 'object'); // note: spawn() has no encoding option of it's own debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')}`); const maxLines = options.maxLines || Number.MAX_SAFE_INTEGER; const logger = options.logger || null; const abortSignal = options.abortSignal || null; // note: we use our own handler and not the child_process one return new Promise((resolve, reject) => { const spawnOptions = _.omit(options, [ 'maxLines', 'logger', 'abortSignal', 'onMessage', 'input', 'encoding', 'timeout', 'onTimeout' ]); const cp = child_process.spawn(file, args, spawnOptions); const stdoutBuffers = [], stderrBuffers = []; let stdoutLineCount = 0, stderrLineCount = 0, killTimerId = null, timedOut = false, terminated = false; cp.stdout.on('data', (data) => { if (logger) return logger(data); stdoutBuffers.push(data); stdoutLineCount += lineCount(data); if (stdoutLineCount >= maxLines) return cp.kill('SIGKILL'); }); cp.stderr.on('data', (data) => { if (logger) return logger(data); stderrBuffers.push(data); stderrLineCount += lineCount(data); if (stderrLineCount >= maxLines) return cp.kill('SIGKILL'); }); cp.on('close', function (code, signal) { // always called. after 'exit' or 'error' const stdoutBuffer = Buffer.concat(stdoutBuffers); const stdout = options.encoding ? stdoutBuffer.toString(options.encoding) : stdoutBuffer; if (killTimerId) clearTimeout(killTimerId); // if terminated or timedout, the code is ignored if (!terminated && !timedOut && code === 0) return resolve(stdout); const stderrBuffer = Buffer.concat(stderrBuffers); const stderr = options.encoding ? stderrBuffer.toString(options.encoding) : stderrBuffer; const e = new BoxError(BoxError.SHELL_ERROR, `${file} exited with code ${code} signal ${signal}`); e.stdout = stdout; // when promisified, this is the way to get stdout e.stdoutLineCount = stdoutLineCount; e.stderr = stderr; // when promisified, this is the way to get stderr e.stderrLineCount = stderrLineCount; e.code = code; e.signal = signal; e.timedOut = timedOut; e.terminated = terminated; debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')} errored`, e); reject(e); }); cp.on('error', function (error) { // when the command itself could not be started debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')} errored`, error); }); cp.terminate = function () { terminated = true; // many approaches to kill sudo launched process failed. we now have a sudo wrapper to kill the full tree child_process.execFile('/usr/bin/sudo', [ KILL_CHILD_CMD, cp.pid, process.pid ], { encoding: 'utf8' }, (error, stdout, stderr) => { if (error) debug(`${tag}: failed to kill children`, stdout, stderr); else debug(`${tag}: terminated ${cp.pid}`, stdout, stderr); }); }; abortSignal?.addEventListener('abort', () => { debug(`${tag}: aborting ${cp.pid}`); cp.terminate(); }, { once: true }); if (options.onMessage) cp.on('message', options.onMessage); // ipc mode messages if (options.timeout) { killTimerId = setTimeout(async () => { debug(`${tag}: timedout`); timedOut = true; if (typeof options.onTimeout !== 'function') return cp.terminate(); await safe(options.onTimeout(), { debug }); }, options.timeout); } // https://github.com/nodejs/node/issues/25231 if ('input' in options) { // when empty, just closes cp.stdin.write(options.input); cp.stdin.end(); } }); } async function bash(tag, script, options) { assert.strictEqual(typeof tag, 'string'); assert.strictEqual(typeof script, 'string'); assert.strictEqual(typeof options, 'object'); return await spawn(tag, '/bin/bash', [ '-c', script ], options); } async function sudo(tag, args, options) { assert.strictEqual(typeof tag, 'string'); assert(Array.isArray(args)); assert.strictEqual(typeof options, 'object'); const sudoArgs = [ '--non-interactive' ]; // avoid prompting the user for input of any kind if (options.preserveEnv) sudoArgs.push('-E'); // -E preserves environment if (options.onMessage) { // enable ipc sudoArgs.push('--close-from=4'); // keep the ipc open. requires closefrom_override in sudoers file options.stdio = ['pipe', 'pipe', 'pipe', 'ipc']; } const spawnArgs = [ ...sudoArgs, ...args ]; return await spawn(tag, SUDO, spawnArgs, options); }