UNPKG

@appium/support

Version:

Support libs used across Appium packages

600 lines (545 loc) 18.2 kB
import _ from 'lodash'; import {promisify} from 'node:util'; import * as yauzl from 'yauzl'; import archiver from 'archiver'; import {createWriteStream} from 'node:fs'; import path from 'node:path'; import stream from 'node:stream'; import {pipeline} from 'node:stream/promises'; import {fs} from './fs'; import {isWindows} from './system'; import {Base64Encode} from 'base64-stream'; import {isSubPath, toReadableSizeString, GiB} from './util'; import {Timer} from './timing'; import log from './logger'; import getStream from 'get-stream'; import {exec} from 'teen_process'; const openZip = promisify(yauzl.open) as ( zipPath: string, options?: yauzl.Options ) => Promise<yauzl.ZipFile>; const ZIP_MAGIC = 'PK'; const IFMT = 0b1111000000000000; const IFDIR = 0b0100000000000000; const IFLNK = 0b1010000000000000; // Internal extraction helpers are defined near the end of the file. export interface ExtractAllOptions { /** * 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. */ fileNamesEncoding?: BufferEncoding; /** * If true, attempt to use system unzip; if this fails, * fallback to the JS unzip implementation. */ useSystemUnzip?: boolean; } /** * Extract zipfile to a directory * * @param zipFilePath The full path to the source ZIP file * @param destDir The full path to the destination folder * @param opts Extraction options */ export async function extractAllTo( zipFilePath: string, destDir: string, opts: ExtractAllOptions = {} ): Promise<void> { 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: any) { log.warn('unzip failed; falling back to JS: %s', err.stderr || err.message); } } const extractor = new ZipExtractor(zipFilePath, { ...(opts as ExtractAllOptions & Partial<ZipExtractorOptions>), dir, }); await extractor.extract(); } /** * Extract a single zip entry to a directory * * @private * @param zipFile The source ZIP stream * @param entry The entry instance * @param destDir The full path to the destination folder */ export async function _extractEntryTo( zipFile: yauzl.ZipFile, entry: yauzl.Entry, destDir: string ): Promise<void> { const dstPath = path.resolve(destDir, entry.fileName); if (!isSubPath(dstPath, destDir)) { throw new Error( `Out of bound path "${dstPath}" found while processing file ${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 Promise<void>((resolve, reject) => { writeStream.once('finish', resolve); writeStream.once('error', reject); }); const openReadStream = promisify(zipFile.openReadStream.bind(zipFile)) as ( entry: yauzl.Entry ) => Promise<NodeJS.ReadableStream>; const zipReadStream = await openReadStream(entry); const zipReadStreamPromise = new Promise<void>((resolve, reject) => { zipReadStream.once('end', resolve); zipReadStream.once('error', reject); }); zipReadStream.pipe(writeStream); // Wait for the zipReadStream and writeStream to end before returning await Promise.all([zipReadStreamPromise, writeStreamPromise]); } export interface ZipEntry { /** The actual entry instance */ entry: yauzl.Entry; /** * Async function which accepts the destination folder path * and extracts this entry into it. */ extractEntryTo: (destDir: string) => Promise<void>; } /** * Get entries for a zip folder * * @param zipFilePath The full path to the source ZIP file * @param 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 be terminated as soon as * the result of this function equals to `false`. */ export async function readEntries( zipFilePath: string, onEntry: (entry: ZipEntry) => boolean | void | Promise<boolean | void> ): Promise<void> { // Open a zip file and start reading entries const zipfile = await openZip(zipFilePath, {lazyEntries: true}); const zipReadStreamPromise = new Promise<void>((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: yauzl.Entry) => { const res = await onEntry({ entry, extractEntryTo: async (destDir: string) => 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 await zipReadStreamPromise; } export interface ZipOptions { /** Whether to encode the resulting archive to a base64-encoded string */ encodeToBase64?: boolean; /** Whether to log the actual archiver performance */ isMetered?: boolean; /** * 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) */ maxSize?: number; /** * The compression level. * The maximum level is 9 (the best compression, worst performance). * The minimum compression level is 0 (no compression). */ level?: number; } /** * Converts contents of local directory to an in-memory .zip buffer * * @param srcPath The full path to the folder or file being zipped * @param opts Zipping options * @returns 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 */ export async function toInMemoryZip( srcPath: string, opts: ZipOptions = {} ): Promise<Buffer> { 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: Buffer[] = []; let resultBuffersSize = 0; // Create a writable stream that zip buffers will be streamed to const resultWriteStream = new stream.Writable({ write(buffer: Buffer, _encoding: string, next: (err?: Error) => void) { 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: number | null = null; const base64EncoderStream = encodeToBase64 ? new Base64Encode() : null; const resultWriteStreamPromise = new Promise<void>((resolve, reject) => { resultWriteStream.once('error', (e: Error) => { 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 Promise<void>((resolve, reject) => { archive.once('finish', resolve); archive.once('error', (e: Error) => 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 Promise.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 filePath - Full path to the file * @throws {Error} If the file does not exist or is not a valid ZIP archive */ export async function assertValidZip(filePath: string): Promise<boolean> { 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); } } export interface ZipCompressionOptions { /** * Compression level in range 0..9 * (greater numbers mean better compression, but longer processing time) */ level?: number; } export interface ZipSourceOptions { /** GLOB pattern for compression */ pattern?: string; /** The source root folder (the parent folder of the destination file by default) */ cwd?: string; /** The list of ignored patterns */ ignore?: string[]; } /** * Creates an archive based on the given glob pattern * * @param dstPath - The resulting archive path * @param src - Source options * @param opts - Compression options * @throws {Error} If there was an error while creating the archive */ export async function toArchive( dstPath: string, src: ZipSourceOptions = {}, opts: ZipCompressionOptions = {} ): Promise<void> { const {level = 9} = opts; const {pattern = '**/*', cwd = path.dirname(dstPath), ignore = []} = src; const archive = archiver('zip', {zlib: {level}}); const outStream = fs.createWriteStream(dstPath); await new Promise<void>((resolve, reject) => { archive .glob(pattern, { cwd, ignore, }) .on('error', reject) .pipe(outStream); outStream .on('error', (e: Error) => { archive.unpipe(outStream); archive.abort(); archive.destroy(); reject(e); }) .on('finish', resolve); archive.finalize(); }); } interface ZipExtractorOptions { dir: string; fileNamesEncoding?: BufferEncoding; defaultDirMode?: string; defaultFileMode?: string; } // This class is mostly copied from https://github.com/maxogden/extract-zip/blob/master/index.js class ZipExtractor { zipfile!: yauzl.ZipFile; private readonly zipPath: string; private readonly opts: ZipExtractorOptions; private canceled = false; constructor(sourcePath: string, opts: ZipExtractorOptions) { this.zipPath = sourcePath; this.opts = opts; } private extractFileName(entry: yauzl.Entry): string { if (Buffer.isBuffer(entry.fileName)) { return entry.fileName.toString(this.opts.fileNamesEncoding); } return entry.fileName; } async extract(): Promise<void> { 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 Promise<void>((resolve, reject) => { this.zipfile.on('error', (err: Error) => { this.canceled = true; reject(err); }); this.zipfile.readEntry(); this.zipfile.on('close', () => { if (!this.canceled) { resolve(); } }); this.zipfile.on('entry', async (entry: yauzl.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('..')) { throw 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); } }); }); } private async extractEntry(entry: yauzl.Entry): Promise<void> { 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: Parameters<typeof fs.mkdir>[1] = {recursive: true}; if (isDir) { mkdirOptions.mode = procMode; } await fs.mkdir(destDir, mkdirOptions); if (isDir) { return; } const openReadStream = promisify( this.zipfile.openReadStream.bind(this.zipfile) ) as (entry: yauzl.Entry) => Promise<NodeJS.ReadableStream>; const readStream = await openReadStream(entry); if (isSymlink) { const link = await getStream(readStream); await fs.symlink(link, dest); } else { await pipeline(readStream, fs.createWriteStream(dest, {mode: procMode})); } } private getExtractedMode(entryMode: number, isDir: boolean): number { 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; } } /** * 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 zipFilePath The full path to the source ZIP file * @param destDir The full path to the destination folder. * This folder is expected to already exist before extracting the archive. */ async function extractWithSystemUnzip(zipFilePath: string, destDir: string): Promise<void> { const isWindowsHost = isWindows(); let executablePath: string; 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]); } } /** * Finds and memoizes the full path to the given executable. * Rejects if it is not found. */ const getExecutablePath = _.memoize( /** * @returns Full Path to the executable */ async function getExecutablePath(binaryName: string): Promise<string> { const fullPath = await fs.which(binaryName); log.debug(`Found '${binaryName}' at '${fullPath}'`); return fullPath; } ); export default { extractAllTo, readEntries, toInMemoryZip, assertValidZip, toArchive, };