UNPKG

archive-wasm

Version:

LibArchive compiled to WASM with a idiomatic JS api

459 lines (419 loc) 16.7 kB
/*! * archive-wasm - LibArchive compiled to WASM with a idiomatic JS API * Copyright (C) 2023 Spacedrive Technology Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ /** * @file Idiomatic JavaScript API for extracting most archive files with LibArchive * @module archive-wasm */ import path from './path.mjs' import { closeArchive, getEntryAtime, getEntryBirthtime, getEntryCtime, getEntryHardlink, getEntryMode, getEntryMtime, getEntryPathName, getEntrySize, getEntrySymlink, getFileData, getNextEntry, openArchive, WARNING, } from './wasm/bridge.mjs' import { EntryTypeName, FILETYPE_FLAG } from './wasm/enums.mjs' import { ARCHIVE_ERRNO_FILE_FORMAT, ArchiveError, ENULL, ExceedRecursionLimitError, ExceedSizeLimitError, FileReadError, NullError, } from './wasm/errors.mjs' import { Pointer } from './wasm/pointer.mjs' const MAX_RECURSIONS_DEPTH = 16 /** * Object type guard * @param {unknown} input * @returns {input is Record<string, unknown>} */ const isObject = input => { return typeof input === 'object' && input !== null && !Array.isArray(input) } /** * Regex[] type guard * @param {unknown} input * @returns {input is RegExp[]} */ const isRegexArray = input => { return Array.isArray(input) && input.every(re => re instanceof RegExp) } /** * Reopen an archive back to a specific entry * @param {Pointer} buffer Archive's data * @param {number} offset Number of entries to skip before returning the entry * @param {string} [passphrase] Passphrase to decrypt protect zip archives * @returns {Pointer} Pointer to the entry's data */ function reopenArchive(buffer, offset, passphrase) { let skips = offset + 1 const archive = openArchive(buffer, passphrase) try { while (--skips >= 0) { try { getNextEntry(archive) } catch (error) { throw error instanceof NullError ? new FileReadError(ENULL, `Couldn't find entry ${offset} inside archive`) : error } } return archive } catch (error) { closeArchive(archive) throw error } } /** * Options for {@link extract} * @typedef {object} ExtractOpts * @property {string} [baseDir] Specifies a base directory to prepend to each extracted entry's path. * @property {string} [encoding] The encoding used to parse entry metadata. Defaults to 'utf8'. * @property {string} [passphrase] Passphrase for decrypting password-protected ZIP archives. * @property {number} [stripComponents] The number of leading path components to skip when extracting entries. Has no effect on absolute paths. The default is 0. * @property {boolean} [normalize] Indicates whether to normalize extracted paths. Defaults to true. * @property {boolean} [recursive] Indicates whether to recursively extract archives within archives. Defaults to false. * @property {boolean} [ignoreDotDir] Indicates whether to ignore entries for '.' directories. Defaults to true. * @property {RegExp[]} [include] A list of RegExp patterns to filter entries that should be extracted. An empty list means all entries are NOT included. * @property {RegExp[]} [exclude] A list of RegExp patterns to filter entries that should be ignored. */ /** * Process extract options * @param {unknown} opts Extract options for the {@link extract} function. * @returns {Required< * Pick<ExtractOpts, 'normalize' | 'recursive' | 'ignoreDotDir' | 'stripComponents'>> & * Pick<ExtractOpts, 'baseDir' | 'encoding' | 'passphrase' * > & { * include: false | NonNullable<ExtractOpts['include']>; * exclude: false | NonNullable<ExtractOpts['exclude']>; * recursionDepth: number * }} */ function processOptions(opts) { /** @type { false | RegExp[]} */ let include = false /** @type { false | RegExp[]} */ let exclude = false let baseDir let encoding let normalize = true let recursive = false let passphrase let ignoreDotDir = true let recursionDepth = 0 let stripComponents = 0 if (opts) { if (typeof opts === 'string') passphrase = opts else if (isObject(opts)) { baseDir = opts.baseDir if (typeof baseDir !== 'string' && baseDir !== undefined) throw new TypeError('Invalid baseDir option, expected a string') encoding = opts.encoding if (typeof encoding !== 'string' && encoding !== undefined) throw new TypeError('Invalid encoding option, expected a string') passphrase = opts.passphrase if (typeof passphrase !== 'string' && passphrase !== undefined) throw new TypeError('Invalid passphrase option, expected a string') if (opts.include != null) { if (!isRegexArray(opts.include)) throw new TypeError('Invalid include option, expected an array of RegExp') include = opts.include } if (opts.exclude != null) { if (!isRegexArray(opts.exclude)) throw new TypeError('Invalid exclude option, expected an array of RegExp') exclude = opts.exclude } if (opts.normalize != null) { if (typeof opts.normalize !== 'boolean') throw new TypeError('Invalid normalize option, expected a boolean') normalize = opts.normalize } if (opts.ignoreDotDir != null) { if (typeof opts.ignoreDotDir !== 'boolean') throw new TypeError('Invalid ignoreDotDir option, expected a boolean') ignoreDotDir = opts.ignoreDotDir } if (opts.stripComponents != null) { if (typeof opts.stripComponents !== 'number') throw new TypeError('Invalid stripComponents option, expected a number') stripComponents = opts.stripComponents } if (typeof opts.recursive === 'boolean') { recursive = opts.recursive } else if ( typeof opts.recursive === 'object' && opts.recursive != null && typeof opts.recursive.valueOf() === 'boolean' && 'depth' in opts.recursive && typeof opts.recursive.depth === 'number' ) { // Decode the recursion depth from a Boolean object placed as the value of the recursive option recursive = /** @type {boolean} */ (opts.recursive.valueOf()) recursionDepth = opts.recursive.depth } } else { throw new TypeError('Invalid options type, expected string or object') } } if (recursionDepth >= MAX_RECURSIONS_DEPTH) throw new ExceedRecursionLimitError() return { include, exclude, baseDir, encoding, normalize, recursive, recursionDepth, stripComponents, passphrase, ignoreDotDir, } } /** * A compressed data entry within an archive. * @typedef {object} Entry * @property {bigint} size The size of the entry in bytes. * @property {number} perm A bit field describing the file type and mode. * @property {string?} path The path of the entry within the archive. * @property {import('./wasm/enums.mjs').EntryTypeName?} type Indicates if the entry is a file, directory, or another type. * @property {string?} link The path to the actual resource if this is a symlink or hardlink. * @property {bigint} atime The timestamp indicating the last time this file was accessed, expressed in nanoseconds since the POSIX Epoch. * @property {bigint} ctime The timestamp indicating the last time the file status was changed, expressed in nanoseconds since the POSIX Epoch. * @property {bigint} mtime The timestamp indicating the last time this file was modified, expressed in nanoseconds since the POSIX Epoch. * @property {bigint} birthtime The timestamp indicating this file’s creation time, expressed in nanoseconds since the POSIX Epoch. * @property {ArrayBuffer} data An ArrayBuffer containing the entry’s data. */ /** * Extract an archive and iterate through all its entries. * * > Using the {@link ExtractOpts.baseDir} or {@link ExtractOpts.stripComponents} option results * in the entry’s path being normalized. * * > The {@link ExtractOpts.stripComponents}, {@link ExtractOpts.include}, and {@link ExtractOpts.exclude} * options are always processed with a normalized version of the entry’s path, so there is no need * to worry about edge cases. Also, they are applied before the {@link ExtractOpts.baseDir} option, * so the base directory will not be affected by them. * * > Using {@link ExtractOpts.recursive} can severely impact performance on large archives. It allows * extracting nested archives (common in GitHub Action releases) but is limited to 16 levels of * recursion. Formats such as `ar`, `empty`, `mtree`, and `cab` are disabled for inner extraction * since they can treat nearly any file as an archive. * @param {ArrayBufferView | ArrayBufferLike} data The archive’s data. * @param {string | ExtractOpts} [opts] Extract options; a string value is interpreted as a password. * @yields {Entry} * @returns {Generator.<Entry, void, void>} A generator that iterates through all the archive’s entries. */ export function* extract(data, opts) { let offset = -1 const { include, exclude, baseDir, encoding, normalize, recursive, recursionDepth, stripComponents, passphrase, ignoreDotDir, } = processOptions(opts) const buffer = data instanceof Pointer ? data : new Pointer().fill(data, true) let archive = openArchive(buffer, passphrase, recursionDepth > 0) try { while (true) { offset++ let pointer try { pointer = getNextEntry(archive) } catch (error) { // Null here means archive EOF if (error instanceof NullError) return throw error } const mode = getEntryMode(pointer) const type = EntryTypeName[FILETYPE_FLAG & mode] ?? null let rawPath = getEntryPathName(pointer, encoding) if (rawPath) { const normalizedPath = path.normalize(path.relative('/', path.resolve(rawPath))) if ( (include && !include.some(re => re.test(normalizedPath))) || (exclude && exclude.some(re => re.test(normalizedPath))) || (ignoreDotDir && type === 'DIR' && normalizedPath === '.') ) continue // Skip leading path components if (stripComponents > 0 && !path.isAbsolute(rawPath)) { const parts = normalizedPath.split('/').slice(stripComponents) if (parts.length === 0) continue rawPath = parts.join('/') } else if (normalize) { rawPath = normalizedPath } } /** @type {Pointer?} */ let fileData = null /** @type {Entry} */ const entry = { perm: ~FILETYPE_FLAG & mode, size: getEntrySize(pointer), type, path: rawPath && !path.isAbsolute(rawPath) && baseDir ? path.relative('/', path.resolve(baseDir)) + '/' + path.relative(baseDir, path.resolve(baseDir, rawPath)) : rawPath, link: getEntrySymlink(pointer, encoding) ?? getEntryHardlink(pointer, encoding), atime: getEntryAtime(pointer), ctime: getEntryCtime(pointer), mtime: getEntryMtime(pointer), birthtime: getEntryBirthtime(pointer), get data() { if (fileData == null) fileData = getFileData(archive, entry.size) const data = fileData.isNull() ? new ArrayBuffer(0) : fileData.read() // Replace the getter with the actual value now that we got it Object.defineProperty(entry, 'data', { value: data }) return data }, } // Extract inner archives if recursive is enabled if (recursive && entry.type === 'FILE' && entry.path && !entry.link) { const innerData = entry.data if (innerData != null && innerData.byteLength > 0) { if (offset === 10 && WARNING) console.warn( 'Using the recursive feature for large archives has a considerable performance impact' ) // Need to close the current archive to open a new one closeArchive(archive) archive = Pointer.NIL // Create a recursion object containing the current depth to pass on the next iteration const recursion = new Boolean(recursive) // eslint-disable-line no-new-wrappers Object.defineProperty(recursion, 'depth', { value: recursionDepth + 1 }) let entryIsArchive = false try { // Extract inner archive and yield it's entries yield* extract(innerData, { baseDir: path.dirname(entry.path), encoding, // @ts-expect-error Major hack, dont do this at home kids XD recursive: recursion, passphrase, ignoreDotDir, }) entryIsArchive = true } catch (error) { // File format errors means that this file is not an archive, so just keep going if (!(error instanceof ArchiveError && error.code === ARCHIVE_ERRNO_FILE_FORMAT)) { throw error } } // Reopen the current archive to continue extracting archive = reopenArchive(buffer, offset, passphrase) if (entryIsArchive) continue } } yield entry // If getter still exists that means the entry's data was not accessed. const entryDataGetter = Object.getOwnPropertyDescriptor(entry, 'data')?.get if (typeof entryDataGetter === 'function') { const entryOffset = offset // Replace the getter with one that opens a new archive to work-around the streaming nature of LibArchive Object.defineProperty(entry, 'data', { get: () => { if (WARNING) console.warn( "Accessing entry's data after the extract loop results in worse performance" ) archive = reopenArchive(buffer, entryOffset, passphrase) try { return entryDataGetter() } finally { closeArchive(archive) archive = Pointer.NIL } }, }) } } } finally { closeArchive(archive) archive = Pointer.NIL } } /** * Exclusive options for {@link extractAll} * @typedef {object} ExtractAllExclusiveOpts * @property {bigint?} [sizeLimit] Limits the total byte size of data to avoid memory exhaustion. Null means no limit (default: 128MB). */ /** * Options for {@link extractAll} * @typedef {ExtractOpts & ExtractAllExclusiveOpts} ExtractAllOpts */ /** * Extracts all entries from an archive. * * > This function is recommended over {@link extract} if you need to retrieve and process the data * from all entries within the archive and memory usage is not a concern. It improves performance * by skipping certain workarounds required for random access to an entry’s data in LibArchive’s * streaming model. If your use case involves accessing all entries and their content, choose * {@link extractAll} for optimal performance. * @param {ArrayBufferView | ArrayBufferLike} data The archive’s data. * @param {string | ExtractAllOpts} [opts] Extraction options, or a string interpreted as a password. * @returns {Entry[]} A list of entries from the archive. */ export function extractAll(data, opts) { let sizeLimit = /** @type {bigint?} */ (128n * 1024n * 1024n) // 128 MB if (opts && typeof opts === 'object') { if (opts.sizeLimit != null) { if (typeof opts.sizeLimit !== 'bigint') throw new TypeError('Invalid sizeLimit option, expected a bigint') sizeLimit = opts.sizeLimit } } return Array.from(extract(data, opts), e => { if (sizeLimit != null && (sizeLimit -= e.size) < 0) throw new ExceedSizeLimitError() void e.data // Force data to load into memory return e }) } export { disableWarning } from './wasm/bridge.mjs' export { EntryTypeName } from './wasm/enums.mjs' export { ArchiveError, NullError, RetryError, FatalError, FailedError, FileReadError, PassphraseError, ExceedSizeLimitError, ExceedRecursionLimitError, } from './wasm/errors.mjs'