@appium/support
Version:
Support libs used across Appium packages
556 lines (505 loc) • 17.6 kB
JavaScript
import _ from 'lodash';
import B from 'bluebird';
import yauzl from 'yauzl';
import archiver from 'archiver';
import {createWriteStream} from 'fs';
import path from 'path';
import stream from 'stream';
import {fs} from './fs';
import {isWindows} from './system';
import {Base64Encode} from 'base64-stream';
import {toReadableSizeString, GiB} from './util';
import {Timer} from './timing';
import log from './logger';
import getStream from 'get-stream';
import {exec} from 'teen_process';
/**
* @type {(path: string, options?: yauzl.Options) => Promise<yauzl.ZipFile>}
*/
// eslint-disable-next-line import/no-named-as-default-member
const openZip = B.promisify(yauzl.open);
/**
* @type {(source: NodeJS.ReadableStream, destination: NodeJS.WritableStream) => Promise<NodeJS.WritableStream>}
*/
const pipeline = B.promisify(stream.pipeline);
const ZIP_MAGIC = 'PK';
const IFMT = 61440;
const IFDIR = 16384;
const IFLNK = 40960;
// This class is mostly copied from https://github.com/maxogden/extract-zip/blob/master/index.js
class ZipExtractor {
/** @type {yauzl.ZipFile} */
zipfile;
constructor(sourcePath, opts = {}) {
this.zipPath = sourcePath;
this.opts = opts;
this.canceled = false;
}
extractFileName(entry) {
return _.isBuffer(entry.fileName)
? entry.fileName.toString(this.opts.fileNamesEncoding)
: entry.fileName;
}
async extract() {
const {dir, fileNamesEncoding} = this.opts;
this.zipfile = await openZip(this.zipPath, {
lazyEntries: true,
// https://github.com/thejoshwolfe/yauzl/commit/cc7455ac789ba84973184e5ebde0581cdc4c3b39#diff-04c6e90faac2675aa89e2176d2eec7d8R95
decodeStrings: !fileNamesEncoding,
});
this.canceled = false;
return new B((resolve, reject) => {
this.zipfile.on('error', (err) => {
this.canceled = true;
reject(err);
});
this.zipfile.readEntry();
this.zipfile.on('close', () => {
if (!this.canceled) {
resolve();
}
});
this.zipfile.on('entry', async (entry) => {
if (this.canceled) {
return;
}
const fileName = this.extractFileName(entry);
if (fileName.startsWith('__MACOSX/')) {
this.zipfile.readEntry();
return;
}
const destDir = path.dirname(path.join(dir, fileName));
try {
await fs.mkdir(destDir, {recursive: true});
const canonicalDestDir = await fs.realpath(destDir);
const relativeDestDir = path.relative(dir, canonicalDestDir);
if (relativeDestDir.split(path.sep).includes('..')) {
new Error(
`Out of bound path "${canonicalDestDir}" found while processing file ${fileName}`
);
}
await this.extractEntry(entry);
this.zipfile.readEntry();
} catch (err) {
this.canceled = true;
this.zipfile.close();
reject(err);
}
});
});
}
async extractEntry(entry) {
if (this.canceled) {
return;
}
const {dir} = this.opts;
const fileName = this.extractFileName(entry);
const dest = path.join(dir, fileName);
// convert external file attr int into a fs stat mode int
const mode = (entry.externalFileAttributes >> 16) & 0xffff;
// check if it's a symlink or dir (using stat mode constants)
const isSymlink = (mode & IFMT) === IFLNK;
const isDir =
(mode & IFMT) === IFDIR ||
// Failsafe, borrowed from jsZip
fileName.endsWith('/') ||
// check for windows weird way of specifying a directory
// https://github.com/maxogden/extract-zip/issues/13#issuecomment-154494566
(entry.versionMadeBy >> 8 === 0 && entry.externalFileAttributes === 16);
const procMode = this.getExtractedMode(mode, isDir) & 0o777;
// always ensure folders are created
const destDir = isDir ? dest : path.dirname(dest);
const mkdirOptions = {recursive: true};
if (isDir) {
mkdirOptions.mode = procMode;
}
await fs.mkdir(destDir, mkdirOptions);
if (isDir) {
return;
}
/** @type {(entry: yauzl.Entry) => Promise<NodeJS.ReadableStream>} */
const openReadStream = B.promisify(this.zipfile.openReadStream.bind(this.zipfile));
const readStream = await openReadStream(entry);
if (isSymlink) {
// @ts-ignore This typecast is ok
const link = await getStream(readStream);
await fs.symlink(link, dest);
} else {
await pipeline(readStream, fs.createWriteStream(dest, {mode: procMode}));
}
}
getExtractedMode(entryMode, isDir) {
const {defaultDirMode, defaultFileMode} = this.opts;
let mode = entryMode;
// Set defaults, if necessary
if (mode === 0) {
if (isDir) {
if (defaultDirMode) {
mode = parseInt(defaultDirMode, 10);
}
if (!mode) {
mode = 0o755;
}
} else {
if (defaultFileMode) {
mode = parseInt(defaultFileMode, 10);
}
if (!mode) {
mode = 0o644;
}
}
}
return mode;
}
}
/**
* @typedef ExtractAllOptions
* @property {string} [fileNamesEncoding] The encoding to use for extracted file names.
* For ZIP archives created on MacOS it is usually expected to be `utf8`.
* By default it is autodetected based on the entry metadata and is only needed to be set explicitly
* if the particular archive does not comply to the standards, which leads to corrupted file names
* after extraction. Only applicable if system unzip binary is NOT being used.
* @property {boolean} [useSystemUnzip] If true, attempt to use system unzip; if this fails,
* fallback to the JS unzip implementation.
*/
/**
* Extract zipfile to a directory
*
* @param {string} zipFilePath The full path to the source ZIP file
* @param {string} destDir The full path to the destination folder
* @param {ExtractAllOptions} [opts]
*/
async function extractAllTo(zipFilePath, destDir, opts = /** @type {ExtractAllOptions} */ ({})) {
if (!path.isAbsolute(destDir)) {
throw new Error(`Target path '${destDir}' is expected to be absolute`);
}
await fs.mkdir(destDir, {recursive: true});
const dir = await fs.realpath(destDir);
if (opts.useSystemUnzip) {
try {
await extractWithSystemUnzip(zipFilePath, dir);
return;
} catch (err) {
log.warn('unzip failed; falling back to JS: %s', err.stderr || err.message);
}
}
const extractor = new ZipExtractor(zipFilePath, {
...opts,
dir,
});
await extractor.extract();
}
/**
* Executes system unzip (e.g., `/usr/bin/unzip`). If available, it is
* significantly faster than the JS implementation.
* By default all files in the destDir get overridden if already exist.
*
* @param {string} zipFilePath The full path to the source ZIP file
* @param {string} destDir The full path to the destination folder.
* This folder is expected to already exist before extracting the archive.
*/
async function extractWithSystemUnzip(zipFilePath, destDir) {
const isWindowsHost = isWindows();
let executablePath;
try {
executablePath = await getExecutablePath(isWindowsHost ? 'powershell.exe' : 'unzip');
} catch {
throw new Error('Could not find system unzip');
}
if (isWindowsHost) {
// on Windows we use PowerShell to unzip files
await exec(executablePath, [
'-command',
'Expand-Archive',
'-LiteralPath',
zipFilePath,
'-DestinationPath',
destDir,
'-Force',
]);
} else {
// -q means quiet (no stdout)
// -o means overwrite
// -d is the dest dir
await exec(executablePath, ['-q', '-o', zipFilePath, '-d', destDir]);
}
}
/**
* Extract a single zip entry to a directory
*
* @param {yauzl.ZipFile} zipFile The source ZIP stream
* @param {yauzl.Entry} entry The entry instance
* @param {string} destDir The full path to the destination folder
*/
async function _extractEntryTo(zipFile, entry, destDir) {
const dstPath = path.resolve(destDir, entry.fileName);
// Create dest directory if doesn't exist already
if (entry.fileName.endsWith('/')) {
if (!(await fs.exists(dstPath))) {
await fs.mkdirp(dstPath);
}
return;
} else if (!(await fs.exists(path.dirname(dstPath)))) {
await fs.mkdirp(path.dirname(dstPath));
}
// Create a write stream
const writeStream = createWriteStream(dstPath, {flags: 'w'});
const writeStreamPromise = new B((resolve, reject) => {
writeStream.once('finish', resolve);
writeStream.once('error', reject);
});
// Create zipReadStream and pipe data to the write stream
// (for some odd reason B.promisify doesn't work on zipfile.openReadStream, it causes an error 'closed')
const zipReadStream = await new B((resolve, reject) => {
zipFile.openReadStream(entry, (err, readStream) => (err ? reject(err) : resolve(readStream)));
});
const zipReadStreamPromise = new B((resolve, reject) => {
zipReadStream.once('end', resolve);
zipReadStream.once('error', reject);
});
zipReadStream.pipe(writeStream);
// Wait for the zipReadStream and writeStream to end before returning
return await B.all([zipReadStreamPromise, writeStreamPromise]);
}
/**
* @typedef ZipEntry
* @property {yauzl.Entry} entry The actual entry instance
* @property {function} extractEntryTo An async function, which accepts one parameter.
* This parameter contains the destination folder path to which this function is going to extract the entry.
*/
/**
* Get entries for a zip folder
*
* @param {string} zipFilePath The full path to the source ZIP file
* @param {function} onEntry Callback when entry is read.
* The callback is expected to accept one argument of ZipEntry type.
* The iteration through the source zip file will bi terminated as soon as
* the result of this function equals to `false`.
*/
async function readEntries(zipFilePath, onEntry) {
// Open a zip file and start reading entries
const zipfile = await openZip(zipFilePath, {lazyEntries: true});
const zipReadStreamPromise = new B((resolve, reject) => {
zipfile.once('end', resolve);
zipfile.once('error', reject);
// On each entry, call 'onEntry' and then read the next entry
zipfile.on('entry', async (entry) => {
const res = await onEntry({
entry,
extractEntryTo: async (destDir) => await _extractEntryTo(zipfile, entry, destDir),
});
if (res === false) {
return zipfile.emit('end');
}
zipfile.readEntry();
});
});
zipfile.readEntry();
// Wait for the entries to finish being iterated through
return await zipReadStreamPromise;
}
/**
* @typedef ZipOptions
* @property {boolean} [encodeToBase64=false] Whether to encode
* the resulting archive to a base64-encoded string
* @property {boolean} [isMetered=true] Whether to log the actual
* archiver performance
* @property {number} [maxSize=1073741824] The maximum size of
* the resulting archive in bytes. This is set to 1GB by default, because
* Appium limits the maximum HTTP body size to 1GB. Also, the NodeJS heap
* size must be enough to keep the resulting object (usually this size is
* limited to 1.4 GB)
* @property {number} [level=9] The compression level. The maximum
* level is 9 (the best compression, worst performance). The minimum
* compression level is 0 (no compression).
*/
/**
* Converts contents of local directory to an in-memory .zip buffer
*
* @param {string} srcPath The full path to the folder or file being zipped
* @param {ZipOptions} opts Zipping options
* @returns {Promise<Buffer>} Zipped (and encoded if `encodeToBase64` is truthy)
* content of the source path as memory buffer
* @throws {Error} if there was an error while reading the source
* or the source is too big
*/
async function toInMemoryZip(srcPath, opts = /** @type {ZipOptions} */ ({})) {
if (!(await fs.exists(srcPath))) {
throw new Error(`No such file or folder: ${srcPath}`);
}
const {isMetered = true, encodeToBase64 = false, maxSize = 1 * GiB, level = 9} = opts;
const resultBuffers = [];
let resultBuffersSize = 0;
// Create a writable stream that zip buffers will be streamed to
const resultWriteStream = new stream.Writable({
write: (buffer, encoding, next) => {
resultBuffers.push(buffer);
resultBuffersSize += buffer.length;
if (maxSize > 0 && resultBuffersSize > maxSize) {
resultWriteStream.emit(
'error',
new Error(
`The size of the resulting ` +
`archive must not be greater than ${toReadableSizeString(maxSize)}`
)
);
}
next();
},
});
// Zip 'srcDir' and stream it to the above writable stream
const archive = archiver('zip', {
zlib: {level},
});
let srcSize = null;
const base64EncoderStream = encodeToBase64 ? new Base64Encode() : null;
const resultWriteStreamPromise = new B((resolve, reject) => {
resultWriteStream.once('error', (e) => {
if (base64EncoderStream) {
archive.unpipe(base64EncoderStream);
base64EncoderStream.unpipe(resultWriteStream);
} else {
archive.unpipe(resultWriteStream);
}
archive.abort();
archive.destroy();
reject(e);
});
resultWriteStream.once('finish', () => {
srcSize = archive.pointer();
resolve();
});
});
const archiveStreamPromise = new B((resolve, reject) => {
archive.once('finish', resolve);
archive.once('error', (e) => reject(new Error(`Failed to archive '${srcPath}': ${e.message}`)));
});
const timer = isMetered ? new Timer().start() : null;
if ((await fs.stat(srcPath)).isDirectory()) {
archive.directory(srcPath, false);
} else {
archive.file(srcPath, {
name: path.basename(srcPath),
});
}
if (base64EncoderStream) {
archive.pipe(base64EncoderStream);
base64EncoderStream.pipe(resultWriteStream);
} else {
archive.pipe(resultWriteStream);
}
archive.finalize();
// Wait for the streams to finish
await B.all([archiveStreamPromise, resultWriteStreamPromise]);
if (timer) {
log.debug(
`Zipped ${encodeToBase64 ? 'and base64-encoded ' : ''}` +
`'${path.basename(srcPath)}' ` +
(srcSize ? `(${toReadableSizeString(srcSize)}) ` : '') +
`in ${timer.getDuration().asSeconds.toFixed(3)}s ` +
`(compression level: ${level})`
);
}
// Return the array of zip buffers concatenated into one buffer
return Buffer.concat(resultBuffers);
}
/**
* Verifies whether the given file is a valid ZIP archive
*
* @param {string} filePath - Full path to the file
* @throws {Error} If the file does not exist or is not a valid ZIP archive
*/
async function assertValidZip(filePath) {
if (!(await fs.exists(filePath))) {
throw new Error(`The file at '${filePath}' does not exist`);
}
const {size} = await fs.stat(filePath);
if (size < 4) {
throw new Error(`The file at '${filePath}' is too small to be a ZIP archive`);
}
const fd = await fs.open(filePath, 'r');
try {
const buffer = Buffer.alloc(ZIP_MAGIC.length);
await fs.read(fd, buffer, 0, ZIP_MAGIC.length, 0);
const signature = buffer.toString('ascii');
if (signature !== ZIP_MAGIC) {
throw new Error(
`The file signature '${signature}' of '${filePath}' ` +
`is not equal to the expected ZIP archive signature '${ZIP_MAGIC}'`
);
}
return true;
} finally {
await fs.close(fd);
}
}
/**
* @typedef ZipCompressionOptions
* @property {number} level [9] - Compression level in range 0..9
* (greater numbers mean better compression, but longer processing time)
*/
/**
* @typedef ZipSourceOptions
* @property {string} [pattern='**\/*'] - GLOB pattern for compression
* @property {string} [cwd] - The source root folder (the parent folder of
* the destination file by default)
* @property {string[]} [ignore] - The list of ignored patterns
*/
/**
* Creates an archive based on the given glob pattern
*
* @param {string} dstPath - The resulting archive path
* @param {ZipSourceOptions} src - Source options
* @param {ZipCompressionOptions} opts - Compression options
* @throws {Error} If there was an error while creating the archive
*/
async function toArchive(
dstPath,
src = /** @type {ZipSourceOptions} */ ({}),
opts = /** @type {ZipCompressionOptions} */ ({})
) {
const {level = 9} = opts;
const {pattern = '**/*', cwd = path.dirname(dstPath), ignore = []} = src;
const archive = archiver('zip', {zlib: {level}});
const stream = fs.createWriteStream(dstPath);
return await new B((resolve, reject) => {
archive
.glob(pattern, {
cwd,
ignore,
})
.on('error', reject)
.pipe(stream);
stream
.on('error', (e) => {
archive.unpipe(stream);
archive.abort();
archive.destroy();
reject(e);
})
.on('finish', resolve);
archive.finalize();
});
}
/**
* Finds and memoizes the full path to the given executable.
* Rejects if it is not found.
*/
const getExecutablePath = _.memoize(
/**
* @returns {Promise<string>} Full Path to the executable
*/
async function getExecutablePath(binaryName) {
const fullPath = await fs.which(binaryName);
log.debug(`Found '${binaryName}' at '${fullPath}'`);
return fullPath;
}
);
export {extractAllTo, readEntries, toInMemoryZip, _extractEntryTo, assertValidZip, toArchive};
export default {
extractAllTo,
readEntries,
toInMemoryZip,
assertValidZip,
toArchive,
};