mirror of
https://github.com/appium/appium.git
synced 2026-02-20 18:30:11 -06:00
* chore(deps): update dependency @types/node to v18.17.6 * Add ignore --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Mykola Mokhnach <mokhnach@gmail.com>
555 lines
17 KiB
JavaScript
555 lines
17 KiB
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>}
|
|
*/
|
|
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 (e) {
|
|
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 (/\/$/.test(entry.fileName)) {
|
|
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,
|
|
};
|