@axway/amplify-utils
Version:
Axway Amplify utility library
885 lines (780 loc) • 23.4 kB
JavaScript
import crypto from 'crypto';
import fs from 'fs';
import pkg from 'lodash';
import semver from 'semver';
import { execSync, ChildProcess, spawnSync } from 'child_process';
import { EventEmitter } from 'events';
import { homedir } from 'os';
import { isFile } from './fs.js';
import { Socket, Server } from 'net';
import * as asyncHooks from 'async_hooks';
import 'path';
/* eslint-disable node/no-deprecated-api, no-new-func */
function getBinding(name) {
try {
return process.binding(name);
} catch (e) {
// squelch
}
return {};
}
const { FSEvent } = getBinding('fs_event_wrap');
const { Timer } = getBinding('timer_wrap');
const { get, set } = pkg;
let archCache = null;
/**
* Returns the current machine's architecture. Possible values are `x64` for 64-bit and `x86` for
* 32-bit (i386/ia32) systems.
*
* @param {Boolean} bypassCache=false - When true, re-detects the system architecture, though it
* will never change.
* @returns {String}
*/
function arch(bypassCache) {
if (archCache && !bypassCache) {
return archCache;
}
// we cache the architecture since it never changes
const platform = process.env.AXWAY_TEST_PLATFORM || process.platform;
archCache = process.env.AXWAY_TEST_ARCH || process.arch;
if (archCache === 'ia32') {
if ((platform === 'win32' && process.env.PROCESSOR_ARCHITEW6432)
|| (platform === 'linux' && /64/.test(execSync('getconf LONG_BIT')))) {
// it's actually 64-bit
archCache = 'x64';
} else {
archCache = 'x86';
}
}
return archCache;
}
/**
* Ensures that a value is an array. If not, it wraps the value in an array.
*
* @param {*} it - The value to ensure is an array.
* @param {Boolean} [removeFalsey=false] - When `true`, filters out all falsey items.
* @returns {Array}
*/
function arrayify(it, removeFalsey) {
const arr = typeof it === 'undefined' ? [] : it instanceof Set ? Array.from(it) : Array.isArray(it) ? it : [ it ];
return removeFalsey ? arr.filter(v => typeof v !== 'undefined' && v !== null && v !== '' && v !== false && (typeof v !== 'number' || !isNaN(v))) : arr;
}
/**
* Validates that the current Node.js version strictly equals the Node engine version in the
* specified package.json.
*
* @param {Object|String} pkgJson - The pkgJson object or the path to the package.json file.
* @returns {Boolean} Returns `true` if the current Node.js version is the exact version required,
* otherwise throws an error.
* @throws {Error} Either the package.json cannot be parsed or the current Node.js version does not
* satisfy the required version.
*/
function assertNodeEngineVersion(pkgJson) {
if (!pkgJson) {
throw new TypeError('Expected pkgJson to be an object or string to a package.json file');
}
if (typeof pkgJson === 'string') {
if (!isFile(pkgJson)) {
throw new Error(`File does not exist: ${pkgJson}`);
}
try {
pkgJson = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
} catch (e) {
throw new Error(`Unable to parse package.json: ${e.message}`);
}
} else if (typeof pkgJson !== 'object' || Array.isArray(pkgJson)) {
throw new TypeError('Expected pkgJson to be an object or string to a package.json file');
}
const current = process.env.AXWAY_TEST_NODE_VERSION || process.version;
const required = pkgJson?.engines?.node;
try {
if (!required || semver.eq(current, required)) {
return true;
}
} catch (e) {
throw new Error(`Invalid Node engine version in package.json: ${required}`);
}
throw new Error(`Requires Node.js '${required}', but the current version is '${current}'`);
}
/**
* A map of store names to cached results.
* @type {Object}
*/
const cacheStore = {};
/**
* Calls a function and caches the result for future calls.
*
* @param {String} name - The name to cache the result under.
* @param {Boolean} [force] - When `true` skips the cache and invokes the function.
* @param {Function} callback - A function to call to get results.
* @returns {Promise<*>} Resolves whatever value `callback` returns/resolves.
*/
async function cache(name, force, callback) {
await new Promise(setImmediate);
if (typeof force === 'function') {
callback = force;
force = false;
}
if (!force && cacheStore[name]) {
return cacheStore[name];
}
return cacheStore[name] = await tailgate(name, callback);
}
/**
* Calls a synchronous function and caches the result for future calls.
*
* @param {String} name - The name to cache the result under.
* @param {Boolean} [force] - When `true` skips the cache and invokes the function.
* @param {Function} callback - A function to call to get results.
* @returns {*} Returns whatever value `callback` returns.
*/
function cacheSync(name, force, callback) {
if (typeof force === 'function') {
callback = force;
force = false;
}
if (typeof name !== 'string' || !name) {
throw new TypeError('Expected name to be a non-empty string');
}
if (typeof callback !== 'function') {
throw new TypeError('Expected callback to be a function');
}
if (!force && cacheStore[name]) {
return cacheStore[name];
}
return cacheStore[name] = callback();
}
/**
* Prevents a function from being called too many times. This function returns a function that can
* be used in a promise chain.
*
* @param {Function} fn - The function to debounce.
* @param {Number} [wait=200] - The number of milliseconds to wait between calls to the returned
* function before firing the specified `fn`.
* @returns {Function}
*/
function debounce(fn, wait = 200) {
let timer;
wait = Math.max(~~wait, 0);
let resolveFn;
let rejectFn;
const promise = new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
function debouncer(...args) {
const ctx = this;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
Promise.resolve()
.then(() => fn.apply(ctx, args))
.then(resolveFn)
.catch(rejectFn);
}, wait);
return promise;
}
debouncer.cancel = function cancel() {
clearTimeout(timer);
timer = null;
};
return debouncer;
}
/**
* Decodes an string with octals to a utf-8 string.
*
* @param {String} input - The string to decode
* @returns {String} The decoded string
*/
function decodeOctalUTF8(input) {
let result = '';
let i = 0;
const l = input.length;
let c;
let octByte;
for (; i < l; i++) {
c = input.charAt(i);
if (c === '\\') {
octByte = input.substring(i + 1, i + 4);
try {
result += String.fromCharCode(parseInt(octByte, 8));
i += 3;
} catch (e) {
result += '\\';
input = octByte + input;
}
} else {
result += c;
}
}
return decodeURIComponent(escape(result));
}
/**
* Formats a number using commas.
*
* @param {Number} n - The number to format.
* @returns {String}
*/
function formatNumber(n) {
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Returns an object with active socket, server, timer, and other handles.
*
* @returns {Object}
*/
function getActiveHandles() {
const handles = { sockets: [], servers: [], timers: [], childProcesses: [], fsWatchers: [], other: [] };
if (typeof process._getActiveHandles === 'function') {
for (let handle of process._getActiveHandles()) {
if (Timer && handle instanceof Timer) {
const timerList = handle._list || handle;
let t = timerList._idleNext;
while (t !== timerList) {
handles.timers.push(t);
t = t._idleNext;
}
} else if (handle instanceof Socket) {
handles.sockets.push(handle);
} else if (handle instanceof Server) {
handles.servers.push(handle);
} else if (handle instanceof ChildProcess) {
handles.childProcesses.push(handle);
} else if (handle instanceof EventEmitter && typeof handle.start === 'function' && typeof handle.close === 'function' && FSEvent && handle._handle instanceof FSEvent) {
handles.fsWatchers.push(handle);
} else {
handles.other.push(handle);
}
}
}
return handles;
}
/**
* Determines if a class extends another class.
*
* @param {Class|Function} subject - The class to check.
* @param {Class|Function|null} base - The base class to look for.
* @returns {Boolean}
*/
function inherits(subject, base) {
if (typeof subject !== 'function') {
throw new TypeError('Expected subject to be a function object');
}
if (base !== null && typeof base !== 'function') {
throw new TypeError('Expected base class to be a function object');
}
let proto = Object.getPrototypeOf(subject);
while (proto !== Function.prototype) {
if (proto === base) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
if (base === Object.getPrototypeOf(subject.prototype)) {
return true;
}
return false;
}
/**
* Removes non-serializable properties and circular references from a value such that it can be
* printed, sent over an IPC channel, or JSON stringified.
*
* @param {*} it - The value to serialize.
* @returns {*}
*/
function makeSerializable(it) {
return (function dupe (src, chain = []) {
const type = typeof src;
if (type === 'number' && isNaN(src)) {
return null;
}
if (src === null || type === 'string' || type === 'number' || type === 'boolean' || src instanceof Date) {
return src;
}
if (type === 'object') {
if (chain.includes(src)) {
return;
}
let result;
chain.push(src);
if (Array.isArray(src)) {
result = src.map(it => dupe(it, chain));
} else {
result = {};
for (let [ key, value ] of Object.entries(src)) {
result[key] = dupe(value, chain);
}
}
chain.pop();
return result;
}
}(it));
}
/**
* Deeply merges two JavaScript objects.
*
* @param {Object} dest - The object to copy the source into.
* @param {Object} src - The object to copy.
* @returns {Object} Returns the dest object.
*/
function mergeDeep(dest, src) {
if (typeof dest !== 'object' || dest === null || Array.isArray(dest)) {
dest = {};
}
if (typeof src !== 'object' || src === null || Array.isArray(src)) {
return dest;
}
for (const key of Object.keys(src)) {
const value = src[key];
if (Array.isArray(value)) {
if (Array.isArray(dest[key])) {
dest[key].push.apply(dest[key], value);
} else {
dest[key] = value.slice();
}
} else if (typeof value === 'object' && value !== null) {
if (typeof dest[key] !== 'object' || dest[key] === null || Array.isArray(dest[key])) {
dest[key] = {};
}
mergeDeep(dest[key], value);
} else if (typeof value !== 'undefined') {
dest[key] = value;
}
}
return dest;
}
/**
* A map of mutex names to each caller's function and promise callbacks.
* @type {Object}
*/
const pendingMutexes = {};
/**
* Ensures that a function is only executed by a single task at a time. If the function is currently
* being run, then additional requests are queued and areexecuted in order when the function
* completes.
*
* @param {String} name - The mutex name.
* @param {Function} callback - A function to call mutually exclusive.
* @returns {Promise} Resolves whatever value `callback` returns/resolves.
*/
async function mutex(name, callback) {
return new Promise((resolve, reject) => {
// we want this promise to resolve as soon as `callback()` finishes
if (typeof name !== 'string' || !name) {
return reject(new TypeError('Expected name to be a non-empty string'));
}
if (typeof callback !== 'function') {
return reject(new TypeError('Expected callback to be a function'));
}
// if another function is current running, add this function to the queue and wait
if (pendingMutexes[name]) {
pendingMutexes[name].push({ callback, resolve, reject });
return;
}
// init the queue
pendingMutexes[name] = [ { callback, resolve, reject } ];
// start a recursive function that drains the queue
(async function next() {
const pending = pendingMutexes[name] && pendingMutexes[name].shift();
if (!pending) {
// all done
delete pendingMutexes[name];
return;
}
// call the function
try {
const result = await pending.callback();
pending.resolve(result);
} catch (err) {
pending.reject(err);
} finally {
next();
}
}());
});
}
/**
* Tries to resolve the operating system name and version.
*
* @returns {Object}
*/
function osInfo() {
let name = null;
let version = null;
switch (process.platform) {
case 'darwin':
{
const stdout = spawnSync('sw_vers').stdout.toString();
let m = stdout.match(/ProductName:\s+(.+)/i);
if (m) {
name = m[1];
}
m = stdout.match(/ProductVersion:\s+(.+)/i);
if (m) {
version = m[1];
}
}
break;
case 'linux':
name = 'GNU/Linux';
if (isFile('/etc/lsb-release')) {
const contents = fs.readFileSync('/etc/lsb-release', 'utf8');
let m = contents.match(/DISTRIB_DESCRIPTION=(.+)/i);
if (m) {
name = m[1].replace(/"/g, '');
}
m = contents.match(/DISTRIB_RELEASE=(.+)/i);
if (m) {
version = m[1].replace(/"/g, '');
}
} else if (isFile('/etc/system-release')) {
const parts = fs.readFileSync('/etc/system-release', 'utf8').split(' ');
if (parts[0]) {
name = parts[0];
}
if (parts[2]) {
version = parts[2];
}
}
break;
case 'win32':
{
const stdout = spawnSync('wmic', [ 'os', 'get', 'Caption,Version' ]).stdout.toString();
const s = stdout.split('\n')[1].split(/ {2,}/);
if (s.length > 0) {
name = s[0].trim() || 'Windows';
}
if (s.length > 1) {
version = s[1].trim() || '';
}
}
break;
}
return {
name,
version
};
}
/**
* Returns the specified number of random bytes as a hex string.
*
* @param {Number} howMany - The number of random bytes to generate. Must be greater than or equal
* to zero.
* @returns {String}
*/
function randomBytes(howMany) {
return crypto.randomBytes(Math.max(~~howMany, 0)).toString('hex');
}
/**
* A lookup of various properties that must be redacted during log message serialization.
* @type {Array.<String|RegExp>}
*/
const mandatoryRedactedProps = [
/clientsecret/i,
/password/i
];
/**
* A list of regexes that will trigger the entire string to be redacted.
* @type {Array.<String|RegExp>}
*/
const mandatoryRedactionTriggers = [
/password/i
];
/**
* A list of string replacement arguments.
* @type {Array.<Array|String>}
*/
const mandatoryReplacements = [
[ homedir(), '<HOME>' ],
process.env.USER, // macOS, Linux
process.env.USERNAME, // Windows
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g // email address
];
/**
* Scrubs any potentially sensitive data from a value. By default, if the source is an object, it
* will be mutated. Redacted properties or elements will not be removed.
*
* @param {*} data - The source object to copy from.
* @param {Object} [opts] - Various options.
* @param {Boolean} [opts.clone] - When `true`, objects and arrays are cloned instead of mutated.
* @param {Array|Set} [opts.props] - A list of properties to redact.
* @param {String} [opts.redacted="<REDACTED>"] - The string to replace redacted words with.
* @param {Array|Set} [opts.replacements] - A list of replacement criteria and an optional value.
* @param {Array|Set} [opts.triggers] - A list of keywords that cause an entire string to be
* redacted.
* @returns {*}
*
* @example
* > redact('foo')
* 'foo'
*
* @example
* > redact('my password is 123456')
* '<REDACTED>'
*
* @example
* > redact({
* info: {
* username: 'chris',
* password: '123456,
* desktop: '/Users/chris/Desktop'
* }
* })
* {
* info: {
* username: '<REDACTED>', // matches process.env.USER
* password: '<REDACTED>', // matches blocked property
* desktop: '~/Desktop' // matches process.env.HOME
* }
* }
*/
function redact(data, opts = {}) {
if (!opts || typeof opts !== 'object') {
throw new TypeError('Expected options to be an object');
}
const redacted = opts.redacted || '<REDACTED>';
const init = (key, value) => {
if (Array.isArray(opts[key]) || opts[key] instanceof Set) {
for (const item of opts[key]) {
if (item && typeof item === 'string') {
value.push(new Function('s', `return s === ${JSON.stringify(item.toLowerCase())}`));
} else if (item instanceof RegExp) {
value.push(item.test.bind(item));
} else {
throw new TypeError(`Expected ${key} to be a set or array of strings or regexes`);
}
}
} else if (opts[key]) {
throw new TypeError(`Expected ${key} to be a set or array of strings or regexes`);
}
return value;
};
const props = init('props', mandatoryRedactedProps.map(re => re.test.bind(re)));
const triggers = init('triggers', mandatoryRedactionTriggers.map(re => re.test.bind(re)));
// init the replacements
const replacementMap = new Map();
const addReplacement = replacements => {
if (Array.isArray(replacements) || replacements instanceof Set) {
for (const replacement of replacements) {
let pattern, value;
if (!replacement) {
continue;
} else if (Array.isArray(replacement)) {
([ pattern, value ] = replacement);
} else if (replacement && (typeof replacement === 'string' || replacement instanceof RegExp)) {
pattern = replacement;
} else {
throw new TypeError('Expected replacements to be an array of replace arguments');
}
const key = pattern;
if (!(pattern instanceof RegExp)) {
// eslint-disable-next-line security/detect-non-literal-regexp
pattern = new RegExp(pattern.replace(/\\/g, '\\\\'), 'ig');
}
if (value === undefined || value === null) {
value = redacted;
}
replacementMap.set(key, s => s.replace(pattern, value));
}
} else if (replacements) {
throw new TypeError('Expected replacements to be an array of replace arguments');
}
};
addReplacement(mandatoryReplacements);
addReplacement(opts.replacements);
const replacements = Array.from(replacementMap.values());
// recursively walk the value and return the result
return (function scrub(src) {
let dest = src;
if (Array.isArray(src)) {
dest = opts.clone ? [] : src;
for (let i = 0, len = src.length; i < len; i++) {
dest[i] = scrub(src[i]);
}
} else if (src && typeof src === 'object') {
dest = opts.clone ? {} : src;
for (const [ key, value ] of Object.entries(src)) {
let match = false;
for (const test of props) {
if (match = test(key)) {
dest[key] = redacted;
break;
}
}
// if we found a match, then we just redacted the whole string and there's no need
// to scrub it
if (!match) {
dest[key] = scrub(value);
}
}
} else if (src && typeof src === 'string') {
for (const replace of replacements) {
dest = replace(dest);
if (dest === redacted) {
break;
}
}
for (const test of triggers) {
if (test(dest)) {
dest = redacted;
break;
}
}
}
return dest;
}(data));
}
/**
* Returns the sha1 of the specified buffer or string.
*
* @param {Buffer|String} data - The buffer or string to hash.
* @returns {String}
*/
function sha1(data) {
return crypto.createHash('sha1').update(Buffer.isBuffer(data) || typeof data === 'string' ? data : JSON.stringify(data)).digest('hex');
}
/**
* Waits a number of milliseconds, then resolves the promise.
*
* @param {Number} ms - The number of milliseconds to wait.
* @returns {Promise}
*/
function sleep(ms) {
return new Promise(resolve => {
if (typeof ms !== 'number') {
throw new TypeError('Expected timeout milliseconds to be a number');
}
if (ms < 0) {
throw new RangeError('Expected timeout milliseconds to be greater than or equal to zero');
}
setTimeout(() => resolve(), ms);
});
}
/**
* A map of tailgate names to each caller's promise callbacks.
* @type {Object}
*/
const pendingTailgaters = {};
/**
* Ensures that only a function is executed by a single task at a time. If a task is already
* running, then additional requests are queued. When the task completes, the result is immediately
* shared with the queued up callers.
*
* @param {String} name - The tailgate name.
* @param {Function} callback - A function to call to get results.
* @returns {Promise} Resolves whatever value `callback` returns/resolves.
*/
function tailgate(name, callback) {
// ensure this function is async
return new Promise((resolve, reject) => {
// we want this promise to resolve as soon as `callback()` finishes
if (typeof name !== 'string' || !name) {
return reject(new TypeError('Expected name to be a non-empty string'));
}
if (typeof callback !== 'function') {
return reject(new TypeError('Expected callback to be a function'));
}
// if another function is current running, add this function to the queue and wait
if (pendingTailgaters[name]) {
pendingTailgaters[name].push({ resolve, reject });
return;
}
// init the queue
pendingTailgaters[name] = [ { resolve, reject } ];
const dispatch = (type, result) => {
const pending = pendingTailgaters[name];
delete pendingTailgaters[name];
for (const p of pending) {
p[type](result);
}
};
// call the function
let result;
try {
result = callback();
} catch (err) {
return dispatch('reject', err);
}
if (result instanceof Promise) {
result
.then(result => dispatch('resolve', result))
.catch(err => dispatch('reject', err));
} else {
dispatch('resolve', result);
}
});
}
let activeTimers = {};
let trackTimerAsyncHook;
let trackTimerWatchers = 0;
/**
* Starts tracking all active timers. Calling the returned callback will stop watching and return
* a list of all active timers.
*
* @returns {Function}
*/
function trackTimers() {
if (!trackTimerAsyncHook) {
try {
// try to initialize the async hook
trackTimerAsyncHook = asyncHooks
.createHook({
init(asyncId, type, triggerAsyncId, resource) {
if (type === 'Timeout') {
activeTimers[asyncId] = resource;
}
},
destroy(asyncId) {
delete activeTimers[asyncId];
}
});
} catch (e) {
// squelch
}
}
if (trackTimerAsyncHook && trackTimerWatchers === 0) {
trackTimerAsyncHook.enable();
}
trackTimerWatchers++;
// result cache just in case stop is called multiple times
let result;
// return the stop tracking callback
return () => {
if (!result) {
trackTimerWatchers--;
if (trackTimerAsyncHook) {
result = Object.values(activeTimers);
if (trackTimerWatchers === 0) {
trackTimerAsyncHook.disable();
// reset the active timers now that we disabled the async hook
activeTimers = {};
}
} else {
result = getActiveHandles().timers;
}
}
return result;
};
}
/**
* Removes duplicates from an array and returns a new array.
*
* @param {Array} arr - The array to remove duplicates.
* @returns {Array}
*/
function unique(arr) {
const len = Array.isArray(arr) ? arr.length : 0;
if (len === 0) {
return [];
}
return arr.reduce((prev, cur) => {
if (typeof cur !== 'undefined' && cur !== null) {
if (prev.indexOf(cur) === -1) {
prev.push(cur);
}
}
return prev;
}, []);
}
export { arch, arrayify, assertNodeEngineVersion, cache, cacheSync, debounce, decodeOctalUTF8, formatNumber, get, getActiveHandles, inherits, makeSerializable, mergeDeep, mutex, osInfo, pendingMutexes, pendingTailgaters, randomBytes, redact, set, sha1, sleep, tailgate, trackTimers, unique };
//# sourceMappingURL=util.js.map