snowflake-sdk
Version:
Node.js driver for Snowflake
808 lines (725 loc) • 24.1 kB
JavaScript
/*
* Copyright (c) 2015-2024 Snowflake Computing Inc. All rights reserved.
*/
const util = require('util');
const Url = require('url');
const os = require('os');
const Logger = require('./logger');
const fs = require('fs');
const Errors = require('./errors');
const ErrorCodes = Errors.codes;
/**
* Note: A simple wrapper around util.inherits() for now, but this might change
* in the future.
*
* Inherits the prototype methods from one constructor into another. The
* prototype of constructor will be set to a new object created from
* superConstructor.
*
* @param constructor
* @param superConstructor
*
* @returns {Object}
*/
exports.inherits = function (constructor, superConstructor) {
return util.inherits.apply(util, [constructor, superConstructor]);
};
/**
* Note: A simple wrapper around util.format() for now, but this will likely
* change in the future.
*
* Returns a formatted string using the first argument as a printf-like format.
*
* The first argument is a string that contains zero or more placeholders.
* Each placeholder is replaced with the converted value from its corresponding
* argument. Supported placeholders are:
* %s - String.
* %d - Number (both integer and float).
* %j - JSON. Replaced with the string '[Circular]' if the argument contains
* circular references.
* %% - single percent sign ('%'). This does not consume an argument.
*
* If the placeholder does not have a corresponding argument, the placeholder is
* not replaced.
*
* If there are more arguments than placeholders, the extra arguments are
* coerced to strings (for objects and symbols, util.inspect() is used) and then
* concatenated, delimited by a space.
*
* If the first argument is not a format string then util.format() returns a
* string that is the concatenation of all its arguments separated by spaces.
* Each argument is converted to a string with util.inspect().
*
* @returns {String}
*/
exports.format = function (format, ...params) {
return util.format.apply(util, [format, ...params]);
};
/**
* Determines if a given value is a function.
*
* @param value
*
* @returns {Boolean}
*/
exports.isFunction = function (value) {
return !!value && typeof value === 'function';
};
const toString = Object.prototype.toString;
/**
* Determines if a given value is an object.
*
* @param value
*
* @returns {Boolean}
*/
exports.isObject = function (value) {
return toString.call(value) === '[object Object]';
};
/**
* Determines if a given value is a Date.
*
* @param value
*
* @returns {Boolean}
*/
exports.isDate = function (value) {
return toString.call(value) === '[object Date]';
};
/**
* Determines if a given value is an array.
*
* @param value
*
* @returns {Boolean}
*/
exports.isArray = function (value) {
return toString.call(value) === '[object Array]';
};
/**
* Determines if a given value is a string.
*
* @param value
*
* @returns {Boolean}
*/
exports.isString = function (value) {
return typeof value === 'string';
};
/**
* Determines if a given value is a boolean.
*
* @param value
*
* @returns {Boolean}
*/
exports.isBoolean = function (value) {
return typeof value === 'boolean';
};
/**
* Determines if a given value is a number.
*
* @param value
*
* @returns {Boolean}
*/
exports.isNumber = function (value) {
return typeof value === 'number' && isFinite(value);
};
/**
* Determines if a given value is a private key string in pem format of type pkcs8.
*
* @param value
*
* @returns {Boolean}
*/
exports.isPrivateKey = function (value) {
const trimmedValue = value.trim();
// The private key is expected to be decrypted when set in the connection string
// secret scanner complains about first check since it looks like private key, but it's only check
// pragma: allowlist nextline secret
return (trimmedValue.startsWith('-----BEGIN PRIVATE KEY-----') &&
trimmedValue.endsWith('\n-----END PRIVATE KEY-----'));
};
/**
* A collection of number-related utility functions.
*/
exports.number =
{
/**
* Determines if a given value is a positive number.
*
* @param value
*
* @returns {Boolean}
*/
isPositive: function (value) {
return exports.isNumber(value) && (value > 0);
},
/**
* Determines if a given value is a non-negative number.
*
* @param value
*
* @returns {Boolean}
*/
isNonNegative: function (value) {
return exports.isNumber(value) && (value >= 0);
},
/**
* Determines if a given value is an integer.
*
* @param value
*
* @returns {Boolean}
*/
isInteger: function (value) {
return exports.isNumber(value) && (Math.floor(value) === value);
},
/**
* Determines if a given value is a positive integer.
*
* @param value
*
* @returns {Boolean}
*/
isPositiveInteger: function (value) {
return this.isInteger(value) && (value > 0);
},
/**
* Determines if a given value is a non-negative integer.
*
* @param value
*
* @returns {Boolean}
*/
isNonNegativeInteger: function (value) {
return this.isInteger(value) && (value >= 0);
}
};
/**
* A collection of string-related utility functions.
*/
exports.string =
{
/**
* Determines if a given string is not null or empty.
*
* @param {*} value
*
* @returns {Boolean}
*/
isNotNullOrEmpty: function (value) {
return exports.isString(value) && value;
},
/**
* Compares two version numbers of the form 'a.b.c' where a, b and c are
* numbers (e.g. '1.0.12'). If one or both inputs are invalid versions, the
* function will return NaN, otherwise, it will return -1 if the first
* version is smaller, 1 if the first version is bigger, and 0 if the two
* versions are equal.
*
* @param {String} version1
* @param {String} version2
*
* @returns {Number}
*/
compareVersions: function (version1, version2) {
// if one or both inputs are valid, return NaN
if (!exports.isString(version1) || !exports.isString(version2)) {
return NaN;
}
// split on dot
const version1Parts = version1.split('.');
const version2Parts = version2.split('.');
// add trailing zeros to make the parts arrays the same length
while (version1Parts.length < version2Parts.length) {
version1Parts.push('0');
}
while (version2Parts.length < version1Parts.length) {
version2Parts.push('0');
}
// compare elements in the two arrays one by one
let result = 0;
let version1Part, version2Part;
for (let index = 0, length = version1Parts.length; index < length; index++) {
// convert to number before doing any arithmetic
version1Part = Number(version1Parts[index]);
version2Part = Number(version2Parts[index]);
// if one or both values are not numerical, consider the input invalid
if (!exports.isNumber(version1Part) || !exports.isNumber(version2Part)) {
result = NaN;
break;
}
// if the two values are different, pick the
// correct result based on which value is smaller
if (version1Part !== version2Part) {
result = version1Part < version2Part ? -1 : 1;
break;
}
}
return result;
}
};
/**
* Determines if a given value is not null or undefined.
*
* @param value
*
* @returns {Boolean}
*/
exports.exists = function (value) {
return (value !== null) && (value !== undefined);
};
/**
* A collection of url-related utility functions.
*/
exports.url =
{
/**
* Appends a query parameter to a url. If an invalid url is specified, an
* exception is thrown.
*
* @param {String} url
* @param {String} paramName the name of the query parameter.
* @param {String} paramValue the value of the query parameter.
*
* @returns {String}
*/
appendParam: function (url, paramName, paramValue) {
// if the specified url is valid
const urlAsObject = Url.parse(url);
if (urlAsObject) {
// if the url already has query parameters, use '&' as the separator
// when appending the additional query parameter, otherwise use '?'
url += (urlAsObject.search ? '&' : '?') + paramName + '=' + paramValue;
}
return url;
},
appendRetryParam: function (option) {
let retryUrl = this.appendParam(option.url, 'retryCount', option.retryCount);
if (option.includeRetryReason) {
retryUrl = this.appendParam(retryUrl, 'retryReason', option.retryReason);
}
return retryUrl;
}
};
/**
* Shallow-copies everything from a source object into a destination object.
*
* @param {Object} dst the object to copy properties to.
* @param {Object} src the object to copy properties from.
*
* @returns {Object} the destination object.
*/
exports.apply = function (dst, src) {
// if both dst and src are objects, copy everything from src to dst
if (this.isObject(dst) && this.isObject(src)) {
for (const key in src) {
if (Object.prototype.hasOwnProperty.call(src, key)) {
dst[key] = src[key];
}
}
}
return dst;
};
/**
* Returns true if the code is currently being run in the browser, false
* otherwise.
*
* @returns {Boolean}
*/
exports.isBrowser = function () {
return !!(process && process.browser);
};
/**
* Returns true if the code is currently being run in node, false otherwise.
*
* @returns {Boolean}
*/
exports.isNode = function () {
return !this.isBrowser();
};
/**
* Returns the next sleep time calculated by exponential backoff with
* decorrelated jitter.
* sleep = min(cap, random_between(base, sleep * 3))
* for more details, check out:
* http://www.awsarchitectureblog.com/2015/03/backoff.html
* @param base minimum seconds
* @param cap maximum seconds
* @param previousSleep previous sleep time
* @returns {number} next sleep time
*/
exports.nextSleepTime = function (
base, cap, previousSleep) {
return Math.min(cap, Math.abs(previousSleep * 3 - base) * Math.random() +
Math.min(base, previousSleep * 3));
};
/**
* Return next sleep time calculated by the jitter rule.
*
* @param {Number} numofRetries
* @param {Number} currentSleepTime
* @param {Number} totalElapsedTime
* @param {Number} maxRetryTimeout
* @returns {JSON} return next sleep Time and totalTime.
*/
exports.getJitteredSleepTime = function (numofRetries, currentSleepTime, totalElapsedTime, maxRetryTimeout) {
const nextsleep = getNextSleepTime(numofRetries, currentSleepTime);
const sleep = maxRetryTimeout !== 0 ? Math.min((maxRetryTimeout - totalElapsedTime), nextsleep) : nextsleep;
totalElapsedTime += sleep;
return { sleep, totalElapsedTime };
};
/**
* Choose one of the number between two numbers.
*
* @param {Number} firstNumber
* @param {Number} secondNumber
* @returns {Number} return a random number between two numbers.
*/
function chooseRandom(firstNumber, secondNumber) {
return Math.random() * (firstNumber - secondNumber) + secondNumber;
}
exports.chooseRandom = chooseRandom;
/**
* return the next sleep Time.
* @param {Number} numofRetries
* @param {Number} currentSleepTime
* @returns {Number} return jitter.
*/
function getNextSleepTime(numofRetries, currentSleepTime) {
const nextSleep = (2 ** (numofRetries));
return chooseRandom(currentSleepTime + getJitter(currentSleepTime), nextSleep + getJitter(currentSleepTime));
}
exports.getNextSleepTime = getNextSleepTime;
/**
* return the jitter value.
* @param {Number} currentSleepTime
* @returns {Number} return jitter.
*/
function getJitter(currentSleepTime) {
const multiplicationFactor = chooseRandom(1, -1);
return 0.5 * currentSleepTime * multiplicationFactor;
}
exports.getJitter = getJitter;
/**
* Check whether the request is the login-request or not.
*
* @param loginurl HTTP request url
* @returns {Boolean} true if it is loginRequest, otherwise false.
*/
exports.isLoginRequest = function (loginUrl) {
const endPoints = ['/v1/login-request', '/authenticator-request',];
return endPoints.some((endPoint) => loginUrl.includes(endPoint));
};
/**
* Checks if the HTTP response code is retryable
*
* @param response HTTP response object
* @param retry403 will retry HTTP 403?
* @returns {*|boolean} true if retryable otherwise false
*/
exports.isRetryableHttpError = function (response, retry403) {
return response &&
((response.statusCode >= 500 && response.statusCode < 600) ||
(retry403 && response.statusCode === 403) ||
(response.statusCode === 408) ||
(response.statusCode === 429));
};
exports.validateClientSessionKeepAliveHeartbeatFrequency = function (input, masterValidity) {
let heartbeatFrequency = input;
const realMax = Math.floor(masterValidity / 4);
const realMin = Math.floor(realMax / 4);
if (input > realMax) {
heartbeatFrequency = realMax;
} else if (input < realMin) {
heartbeatFrequency = realMin;
}
heartbeatFrequency = Math.floor(heartbeatFrequency);
return heartbeatFrequency;
};
// driver name
const driverName = require('./../package.json').name;
exports.driverName = driverName;
// driver version
const driverVersion = require('./../package.json').version;
exports.driverVersion = driverVersion;
// nodeJS version
let nodeJSVersion = process.version;
if (nodeJSVersion && nodeJSVersion.startsWith('v')) {
nodeJSVersion = nodeJSVersion.substring(1);
}
// user-agent HTTP header
const userAgent = 'JavaScript' + '/' + driverVersion
+ ' (' + process.platform + '-' + process.arch + ') ' + 'NodeJS' + '/' + nodeJSVersion;
exports.userAgent = userAgent;
/**
* Constructs host name using region and account
*
* @param region where the account is located
* @param account which account to connect to
* @returns {string} host name
*/
exports.constructHostname = function (region, account) {
let host;
if (region === 'us-west-2') {
host = account + '.snowflakecomputing.com';
} else if (region != null) {
if (account.indexOf('.') > 0) {
account = account.substring(0, account.indexOf('.'));
}
if (region.startsWith('cn-') || region.startsWith('CN-')) {
host = account + '.' + region + '.snowflakecomputing.cn';
} else {
host = account + '.' + region + '.snowflakecomputing.com';
}
} else {
host = account + '.snowflakecomputing.com';
}
return host;
};
/**
* Returns true if host indicates private link
*
* @returns {boolean}
*/
exports.isPrivateLink = function (host) {
Errors.checkArgumentExists(this.exists(host), ErrorCodes.ERR_CONN_CREATE_MISSING_HOST);
return host.toLowerCase().includes('privatelink.snowflakecomputing.');
};
/**
* Returns true if host indicates private link
*
* @returns {boolean}
*/
exports.createOcspResponseCacheServerUrl = function (host) {
return `http://ocsp.${host}/ocsp_response_cache.json`;
};
/**
* Returns if command is a PUT command
*
* @param sqlText the query command
* @returns {boolean}
*/
exports.isPutCommand = function (sqlText) {
return (sqlText.trim().substring(0, 3).toUpperCase() === 'PUT');
};
/**
* Returns if command is a GET command
*
* @param sqlText the query command
* @returns {boolean}
*/
exports.isGetCommand = function (sqlText) {
return (sqlText.trim().substring(0, 3).toUpperCase() === 'GET');
};
/**
* Add double quotes to smkId's value to parse it as a string instead of integer
* to preserve precision of numbers exceeding JavaScript's max safe integer
* e.g (inputting 32621973126123526 outputs 32621973126123530)
*
* @param body the data in JSON
* @returns {string}
*/
exports.convertSmkIdToString = function (body) {
return body.replace(/"smkId" : ([0-9]*)/g, '"smkId" : "$1"');
};
/**
* Under some circumstances the object passed to JSON.stringify in exception handling
* can contain circular reference, on which JSON.stringify bails out
* MDN way of handling such error
* @returns string
*/
exports.getCircularReplacer = function () {
const ancestors = [];
return function (key, value) {
if (typeof value !== 'object' || value === null) {
return value;
}
// `this` is the object that value is contained in,
// i.e., its direct parent.
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop();
}
if (ancestors.includes(value)) {
return '[Circular]';
}
ancestors.push(value);
return value;
};
};
/**
* Returns if the provided string is a valid subdomain.
* @param value
* @returns {boolean}
*/
exports.isCorrectSubdomain = function (value) {
const subdomainRegex = RegExp(/^\w+([.-]\w+)*$/i);
return subdomainRegex.test(value);
};
/**
* Try to get the PROXY environmental variables
* On Windows, envvar name is case-insensitive, but on *nix, it's case-sensitive
*
* Compare them with the proxy specified on the Connection, if any
* Return with the log constructed from the components detection and comparison
* If there's something to warn the user about, return that too
*
* @param the agentOptions object from agent creation
* @returns {object}
*/
exports.getCompareAndLogEnvAndAgentProxies = function (agentOptions) {
const envProxy = {};
const logMessages = { 'messages': '', 'warnings': '' };
envProxy.httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
envProxy.httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
envProxy.noProxy = process.env.NO_PROXY || process.env.no_proxy;
envProxy.logHttpProxy = envProxy.httpProxy ?
'HTTP_PROXY: ' + envProxy.httpProxy : 'HTTP_PROXY: <unset>';
envProxy.logHttpsProxy = envProxy.httpsProxy ?
'HTTPS_PROXY: ' + envProxy.httpsProxy : 'HTTPS_PROXY: <unset>';
envProxy.logNoProxy = envProxy.noProxy ?
'NO_PROXY: ' + envProxy.noProxy : 'NO_PROXY: <unset>';
// log PROXY envvars
if (envProxy.httpProxy || envProxy.httpsProxy) {
logMessages.messages = logMessages.messages + ' // PROXY environment variables: '
+ `${envProxy.logHttpProxy} ${envProxy.logHttpsProxy} ${envProxy.logNoProxy}.`;
}
// log proxy config on Connection, if any set
if (agentOptions.host) {
const proxyHostAndPort = agentOptions.host + ':' + agentOptions.port;
const proxyProtocolHostAndPort = agentOptions.protocol ?
' protocol=' + agentOptions.protocol + ' proxy=' + proxyHostAndPort
: ' proxy=' + proxyHostAndPort;
const proxyUsername = agentOptions.user ? ' user=' + agentOptions.user : '';
logMessages.messages = logMessages.messages + ` // Proxy configured in Connection:${proxyProtocolHostAndPort}${proxyUsername}`;
// check if both the PROXY envvars and Connection proxy config is set
// generate warnings if they are, and are also different
if (envProxy.httpProxy &&
this.removeScheme(envProxy.httpProxy).toLowerCase() !== this.removeScheme(proxyHostAndPort).toLowerCase()) {
logMessages.warnings = logMessages.warnings + ` Using both the HTTP_PROXY (${envProxy.httpProxy})`
+ ` and the proxyHost:proxyPort (${proxyHostAndPort}) settings to connect, but with different values.`
+ ' If you experience connectivity issues, try unsetting one of them.';
}
if (envProxy.httpsProxy &&
this.removeScheme(envProxy.httpsProxy).toLowerCase() !== this.removeScheme(proxyHostAndPort).toLowerCase()) {
logMessages.warnings = logMessages.warnings + ` Using both the HTTPS_PROXY (${envProxy.httpsProxy})`
+ ` and the proxyHost:proxyPort (${proxyHostAndPort}) settings to connect, but with different values.`
+ ' If you experience connectivity issues, try unsetting one of them.';
}
}
logMessages.messages = logMessages.messages ? logMessages.messages : ' none.';
return logMessages;
};
exports.buildCredentialCacheKey = function (host, username, credType) {
if (!host || !username || !credType) {
Logger.getInstance().debug('Cannot build the credential cache key because one of host, username, and credType is null');
return null;
}
return `{${host.toUpperCase()}}:{${username.toUpperCase()}}:{SF_NODE_JS_DRIVER}:{${credType.toUpperCase()}}`;
};
/**
*
* @param {Object} customCredentialManager
* @returns
*/
exports.checkValidCustomCredentialManager = function (customCredentialManager) {
if ( typeof customCredentialManager !== 'object') {
return false;
}
const requireMethods = ['write', 'read', 'remove'];
for (const method of requireMethods) {
if (!Object.hasOwnProperty.call(customCredentialManager, method) || typeof customCredentialManager[method] !== 'function') {
return false;
}
}
return true;
};
exports.checkParametersDefined = function (...parameters) {
return parameters.every((element) => element !== undefined && element !== null);
};
/**
* remove http:// or https:// from the input, e.g. used with proxy URL
* @param input
* @returns {string}
*/
exports.removeScheme = function (input) {
return input.toString().replace(/(^\w+:|^)\/\//, '');
};
exports.buildCredentialCacheKey = function (host, username, credType) {
if (!host || !username || !credType) {
Logger.getInstance().debug('Cannot build the credential cache key because one of host, username, and credType is null');
return null;
}
return `{${host.toUpperCase()}}:{${username.toUpperCase()}}:{SF_NODE_JS_DRIVER}:{${credType.toUpperCase()}}`;
};
/**
*
* @param {Object} customCredentialManager
* @returns
*/
exports.checkValidCustomCredentialManager = function (customCredentialManager) {
if ( typeof customCredentialManager !== 'object') {
return false;
}
const requireMethods = ['write', 'read', 'remove'];
for (const method of requireMethods) {
if (!Object.hasOwnProperty.call(customCredentialManager, method) || typeof customCredentialManager[method] !== 'function') {
return false;
}
}
return true;
};
exports.checkParametersDefined = function (...parameters) {
return parameters.every((element) => element !== undefined && element !== null);
};
exports.shouldPerformGCPBucket = function (accessToken) {
return !!accessToken && process.env.SNOWFLAKE_FORCE_GCP_USE_DOWNSCOPED_CREDENTIAL !== 'true';
};
/**
* Checks if the provided file or directory permissions are correct.
* @param filePath
* @param expectedMode
* @param fsPromises
* @returns {Promise<boolean>} resolves always to true for Windows
*/
exports.isFileModeCorrect = async function (filePath, expectedMode, fsPromises) {
if (os.platform() === 'win32') {
return true;
}
return await fsPromises.stat(filePath).then((stats) => {
// we have to limit the number of LSB bits to 9 with the mask, as the stats.mode starts with the file type,
// e.g. the directory with permissions 755 will have stats.mask of 40755.
const mask = (1 << 9) - 1;
return (stats.mode & mask) === expectedMode;
});
};
/**
* Checks if the provided file or directory is writable only by the user.
* @param configFilePath
* @param fsPromises
* @returns {Promise<boolean>} resolves always to true for Windows
*/
exports.isFileNotWritableByGroupOrOthers = async function (configFilePath, fsPromises) {
if (os.platform() === 'win32') {
return true;
}
const stats = await fsPromises.stat(configFilePath);
return (stats.mode & (1 << 4)) === 0 && (stats.mode & (1 << 1)) === 0;
};
exports.shouldRetryOktaAuth = function ({ maxRetryTimeout, maxRetryCount, numRetries, startTime, remainingTimeout }) {
return (maxRetryTimeout === 0 || Date.now() < startTime + remainingTimeout) && numRetries <= maxRetryCount;
};
exports.getDriverDirectory = function () {
return __dirname;
};
exports.validatePath = function (dir) {
try {
const stat = fs.statSync(dir);
return stat.isDirectory();
} catch {
Logger.getInstance().error('The path location is invalid. Please check this location is accessible or existing');
return false;
}
};