340 lines
11 KiB
JavaScript
340 lines
11 KiB
JavaScript
var Node = {
|
|
child: require('child_process'),
|
|
crypto: require('crypto'),
|
|
fs: require('fs'),
|
|
os: require('os'),
|
|
path: require('path'),
|
|
process: process,
|
|
util: require('util')
|
|
};
|
|
|
|
function attempt(attempts, command, options, end) {
|
|
if (typeof attempts !== 'number' || Math.floor(attempts) !== attempts || attempts < 0) {
|
|
return end(new Error('Attempts argument should be a positive integer.'));
|
|
}
|
|
// The -n (non-interactive) option prevents sudo from prompting the user for
|
|
// a password. If a password is required for the command to run, sudo will
|
|
// display an error message and exit.
|
|
var childProcess = Node.child.exec('/usr/bin/sudo -n ' + command,
|
|
function(error, stdout, stderr) {
|
|
if (/sudo: /i.test(stderr)) {
|
|
if (attempts > 0) return end(new Error('User did not grant permission.'));
|
|
if (Node.process.platform === 'linux') {
|
|
// Linux will probably use TTY tickets for sudo timestamps.
|
|
// If so, we cannot easily extend the sudo timestamp for the user.
|
|
// We prefer this since a single prompt can be used for multiple calls.
|
|
// Instead, we have to use a separate prompt for each call.
|
|
return linux(command, options, end);
|
|
}
|
|
prompt(options,
|
|
function(error) {
|
|
if (error) return end(error);
|
|
attempt(++attempts, command, options, end); // Cannot use ++ suffix here.
|
|
}
|
|
);
|
|
} else {
|
|
end(error, stdout, stderr);
|
|
}
|
|
}
|
|
);
|
|
if (options.onChildProcess) options.onChildProcess(childProcess);
|
|
}
|
|
|
|
function copy(source, target, end) {
|
|
source = escapeDoubleQuotes(Node.path.normalize(source));
|
|
target = escapeDoubleQuotes(Node.path.normalize(target));
|
|
var command = '/bin/cp -R -p "' + source + '" "' + target + '"';
|
|
Node.child.exec(command, end);
|
|
}
|
|
|
|
function escapeDoubleQuotes(string) {
|
|
return string.replace(/"/g, '\\"');
|
|
}
|
|
|
|
function exec() {
|
|
if (arguments.length < 1 || arguments.length > 3) {
|
|
throw new Error('Wrong number of arguments.');
|
|
}
|
|
var command = arguments[0];
|
|
var options = {};
|
|
var end = function() {};
|
|
if (typeof command !== 'string') {
|
|
throw new Error('Command should be a string.');
|
|
}
|
|
if (arguments.length === 2) {
|
|
if (Node.util.isObject(arguments[1])) {
|
|
options = arguments[1];
|
|
} else if (Node.util.isFunction(arguments[1])) {
|
|
end = arguments[1];
|
|
} else {
|
|
throw new Error('Expected options or callback.');
|
|
}
|
|
} else if (arguments.length === 3) {
|
|
if (Node.util.isObject(arguments[1])) {
|
|
options = arguments[1];
|
|
} else {
|
|
throw new Error('Expected options to be an object.');
|
|
}
|
|
if (Node.util.isFunction(arguments[2])) {
|
|
end = arguments[2];
|
|
} else {
|
|
throw new Error('Expected callback to be a function.');
|
|
}
|
|
}
|
|
if (/^sudo/i.test(command)) {
|
|
return end(new Error('Command should not contain "sudo".'));
|
|
}
|
|
if (typeof options.name === 'undefined') {
|
|
if (typeof name === 'string') {
|
|
// If name is a string, it has been set and verified by setName.
|
|
options.name = name;
|
|
} else {
|
|
var title = Node.process.title;
|
|
if (validName(title)) {
|
|
options.name = title;
|
|
} else {
|
|
return end(new Error('options.name must be provided (process.title is not valid).'));
|
|
}
|
|
}
|
|
} else if (!validName(options.name)) {
|
|
return end(new Error('options.name must be alphanumeric only (spaces are allowed).'));
|
|
}
|
|
if (typeof options.icns !== 'undefined') {
|
|
if (typeof options.icns !== 'string') {
|
|
return end(new Error('options.icns must be a string if provided.'));
|
|
} else if (options.icns.trim().length === 0) {
|
|
return end(new Error('options.icns must be a non-empty string if provided.'));
|
|
}
|
|
}
|
|
if (typeof options.onChildProcess !== 'undefined') {
|
|
if (typeof options.onChildProcess !== 'function') {
|
|
return end(new Error('options.onChildProcess must be a function if provided.'));
|
|
}
|
|
}
|
|
if (Node.process.platform !== 'darwin' && Node.process.platform !== 'linux') {
|
|
return end(new Error('Platform not yet supported.'));
|
|
}
|
|
attempt(0, command, options, end);
|
|
}
|
|
|
|
function linux(command, options, end) {
|
|
linuxBinary(
|
|
function(error, binary) {
|
|
if (error) return end(error);
|
|
linuxExecute(binary, command, options, end);
|
|
}
|
|
);
|
|
}
|
|
|
|
function linuxBinary(end) {
|
|
var index = 0;
|
|
// We prefer gksudo over pkexec since it gives a nicer prompt:
|
|
var paths = ['/usr/bin/gksudo', '/usr/bin/pkexec', '/usr/bin/kdesudo'];
|
|
function test() {
|
|
if (index === paths.length) {
|
|
return end(new Error('Unable to find gksudo, pkexec or kdesudo.'));
|
|
}
|
|
var path = paths[index++];
|
|
Node.fs.stat(path,
|
|
function(error) {
|
|
if (error) {
|
|
if (error.code === 'ENOTDIR' || error.code === 'ENOENT') {
|
|
return test();
|
|
} else {
|
|
return end(error);
|
|
}
|
|
} else {
|
|
end(undefined, path);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
test();
|
|
}
|
|
|
|
function linuxExecute(binary, command, options, end) {
|
|
var string = '';
|
|
string += '"' + escapeDoubleQuotes(binary) + '" ';
|
|
if (/gksudo/i.test(binary)) {
|
|
string += '--preserve-env ';
|
|
string += '--sudo-mode ';
|
|
string += '--description="' + escapeDoubleQuotes(options.name) + '" ';
|
|
} else if (/pkexec/i.test(binary)) {
|
|
string += '--disable-internal-agent ';
|
|
}
|
|
string += command;
|
|
var childProcess = Node.child.exec(string,
|
|
function(error, stdout, stderr) {
|
|
if (error && /Request dismissed|Command failed/i.test(error)) {
|
|
error = new Error('User did not grant permission.');
|
|
}
|
|
end(error, stdout, stderr);
|
|
}
|
|
);
|
|
if (options.onChildProcess) options.onChildProcess(childProcess);
|
|
}
|
|
|
|
function macIcon(target, options, end) {
|
|
if (!options.icns) return end();
|
|
copy(options.icns, Node.path.join(target, 'Contents', 'Resources', 'applet.icns'), end);
|
|
}
|
|
|
|
function macOpen(target, options, end) {
|
|
target = escapeDoubleQuotes(Node.path.normalize(target));
|
|
var command = 'open -n -W "' + target + '"';
|
|
Node.child.exec(command, end);
|
|
}
|
|
|
|
function macPrompt(hash, options, callback) {
|
|
var temp = Node.os.tmpdir();
|
|
if (!temp) return callback(new Error('Requires os.tmpdir() to be defined.'));
|
|
if (!Node.process.env.USER) return callback(new Error('Requires env[\'USER\'] to be defined.'));
|
|
var source = Node.path.join(Node.path.dirname(module.filename), 'applet.app');
|
|
var target = Node.path.join(temp, hash, options.name + '.app');
|
|
function end(error) {
|
|
remove(Node.path.dirname(target),
|
|
function(errorRemove) {
|
|
if (error) return callback(error);
|
|
if (errorRemove) return callback(errorRemove);
|
|
callback();
|
|
}
|
|
);
|
|
}
|
|
Node.fs.mkdir(Node.path.dirname(target),
|
|
function(error) {
|
|
if (error && error.code === 'EEXIST') error = undefined;
|
|
if (error) return end(error);
|
|
copy(source, target,
|
|
function(error) {
|
|
if (error) return end(error);
|
|
macIcon(target, options,
|
|
function(error) {
|
|
if (error) return end(error);
|
|
macPropertyList(target, options,
|
|
function(error) {
|
|
if (error) return end(error);
|
|
macOpen(target, options, end);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
function macPropertyList(target, options, end) {
|
|
// Value must be in single quotes (not double quotes) according to man entry.
|
|
// e.g. defaults write com.companyname.appname "Default Color" '(255, 0, 0)'
|
|
// The defaults command will be changed in an upcoming major release to only
|
|
// operate on preferences domains. General plist manipulation utilities will
|
|
// be folded into a different command-line program.
|
|
var path = escapeDoubleQuotes(Node.path.join(target, 'Contents', 'Info.plist'));
|
|
var key = escapeDoubleQuotes('CFBundleName');
|
|
var value = options.name + ' Password Prompt';
|
|
if (/'/.test(value)) {
|
|
return end(new Error('Value should not contain single quotes.'));
|
|
}
|
|
var command = 'defaults write "' + path + '" "' + key + '" \'' + value + '\'';
|
|
Node.child.exec(command, end);
|
|
}
|
|
|
|
var name = null;
|
|
|
|
function prompt(options, end) {
|
|
version(options,
|
|
function(error, hash) {
|
|
if (error) return end(error);
|
|
if (!prompting.hasOwnProperty(hash)) prompting[hash] = [];
|
|
prompting[hash].push(end);
|
|
// Already waiting for user to enter password...
|
|
// We expect that exec() may be called multiple times.
|
|
// If a prompt is already pending, then we wait for the result of the prompt
|
|
// and do not trigger another permission request dialog.
|
|
if (prompting[hash].length > 1) return;
|
|
function done(error) {
|
|
// We must clear prompting queue before looping, otherwise sudo calls which
|
|
// are synchronously issued by these callbacks may fail to be executed.
|
|
var callbacks = prompting[hash];
|
|
delete prompting[hash];
|
|
for (var index = 0, length = callbacks.length; index < length; index++) {
|
|
var callback = callbacks[index];
|
|
callback(error);
|
|
}
|
|
}
|
|
if (Node.process.platform === 'darwin') return macPrompt(hash, options, done);
|
|
if (Node.process.platform === 'linux') return linuxPrompt(hash, options, done);
|
|
end(new Error('Platform not supported (unexpected, should have been checked already).'));
|
|
}
|
|
);
|
|
}
|
|
|
|
var prompting = {};
|
|
|
|
function remove(target, end) {
|
|
if (!target) return end(new Error('Target not defined.'));
|
|
target = escapeDoubleQuotes(Node.path.normalize(target));
|
|
var command = 'rm -rf "' + target + '"';
|
|
Node.child.exec(command, end);
|
|
}
|
|
|
|
function setName(string) {
|
|
// DEPRECATED to move away from a global variable towards a functional
|
|
// interface. Otherwise using setName could have rare race conditions when
|
|
// multiple calls need to use different names.
|
|
if (!validName(string)) {
|
|
throw new Error('Name must be alphanumeric only (spaces are allowed).');
|
|
}
|
|
name = string;
|
|
}
|
|
|
|
function touch(end) {
|
|
// DEPRECATED to reduce the surface area of the interface.
|
|
// Better to call exec() directly as this supports the options argument.
|
|
// touch() may fail if process.title is not valid.
|
|
// Depends on setName() which has also been deprecated.
|
|
// This is a convenience method to extend the sudo session.
|
|
// This uses existing sudo-prompt machinery.
|
|
exec('echo touchingsudotimestamp', {},
|
|
function(error, stdout, stderr) {
|
|
if (error) return end(error);
|
|
end(); // Do not pass stdout and stderr back to callback.
|
|
}
|
|
);
|
|
}
|
|
|
|
function validName(string) {
|
|
// We use 70 characters as a limit to side-step any issues with Unicode
|
|
// normalization form causing a 255 character string to exceed the fs limit.
|
|
return /^[a-z0-9 ]+$/i.test(string) && string.trim().length > 0 && string.length < 70;
|
|
}
|
|
|
|
function version(options, end) {
|
|
versionReadICNS(options,
|
|
function(error, buffer) {
|
|
if (error) return end(error);
|
|
var hash = Node.crypto.createHash('SHA256');
|
|
hash.update('sudo-prompt 2.0.0');
|
|
hash.update(options.name);
|
|
hash.update(buffer);
|
|
end(undefined, hash.digest('hex').slice(-32));
|
|
}
|
|
);
|
|
}
|
|
|
|
function versionReadICNS(options, end) {
|
|
if (!options.icns || Node.process.platform !== 'darwin') {
|
|
return end(undefined, new Buffer(0));
|
|
}
|
|
// options.icns is supported only on Mac.
|
|
Node.fs.readFile(options.icns, end);
|
|
}
|
|
|
|
module.exports.exec = exec;
|
|
|
|
// DEPRECATED:
|
|
module.exports.setName = setName;
|
|
|
|
// DEPRECATED:
|
|
module.exports.touch = touch;
|