diff --git a/helpers/scanner/file-api.js b/helpers/scanner/file-api.js deleted file mode 100644 index 16450a6..0000000 --- a/helpers/scanner/file-api.js +++ /dev/null @@ -1,333 +0,0 @@ -import { EventEmitter } from 'events' - -export function File (input) { - var self = this; - - function updateStat(stat) { - self.stat = stat; - self.lastModifiedDate = self.stat.mtime; - self.size = self.stat.size; - } - - if ('string' === typeof input) { - self.path = input; - } else { - Object.keys(input).forEach(function (k) { - self[k] = input[k]; - }); - } - - self.name = self.name// || path.basename(self.path||''); - if (!self.name) { - throw new Error("No name"); - } - self.type = self.type// || mime.lookup(self.name); - - if (!self.path) { - if (self.buffer) { - self.size = self.buffer.length; - } else if (!self.stream) { - throw new Error('No input, nor stream, nor buffer.'); - } - return; - } - - if (!self.jsdom) { - return; - } - - // if (!self.async) { - // updateStat(fs.statSync(self.path)); - // } else { - // fs.stat(self.path, function (err, stat) { - // updateStat(stat); - // }); - // } -} - - -function doop(fn, args, context) { - if ('function' === typeof fn) { - fn.apply(context, args); - } -} - -function toDataUrl(data, type) { - // var data = self.result; - var dataUrl = 'data:'; - - if (type) { - dataUrl += type + ';'; - } - - if (/text/i.test(type)) { - dataUrl += 'charset=utf-8,'; - dataUrl += data.toString('utf8'); - } else { - dataUrl += 'base64,'; - dataUrl += data.toString('base64'); - } - - return dataUrl; -} - -function mapDataToFormat(file, data, format, encoding) { - // var data = self.result; - - switch (format) { - case 'buffer': - return data; - break; - case 'binary': - return data.toString('binary'); - break; - case 'dataUrl': - return toDataUrl(data, file.type); - break; - case 'text': - return data.toString(encoding || 'utf8'); - break; - } -} - -export function FileReader () { - var self = this, - emitter = new EventEmitter, - file; - - self.addEventListener = function(on, callback) { - emitter.on(on, callback); - }; - self.removeEventListener = function(callback) { - emitter.removeListener(callback); - } - self.dispatchEvent = function(on) { - emitter.emit(on); - } - - self.EMPTY = 0; - self.LOADING = 1; - self.DONE = 2; - - self.error = undefined; // Read only - self.readyState = self.EMPTY; // Read only - self.result = undefined; // Road only - - // non-standard - self.on = function() { - emitter.on.apply(emitter, arguments); - } - self.nodeChunkedEncoding = false; - self.setNodeChunkedEncoding = function(val) { - self.nodeChunkedEncoding = val; - }; - // end non-standard - - - - // Whatever the file object is, turn it into a Node.JS File.Stream - function createFileStream() { - var stream = new EventEmitter(), - chunked = self.nodeChunkedEncoding; - - // attempt to make the length computable - // if (!file.size && chunked && file.path) { - // fs.stat(file.path, function(err, stat) { - // file.size = stat.size; - // file.lastModifiedDate = stat.mtime; - // }); - // } - - - // The stream exists, do nothing more - if (file.stream) { - return; - } - - - // Create a read stream from a buffer - if (file.buffer) { - process.nextTick(function() { - stream.emit('data', file.buffer); - stream.emit('end'); - }); - file.stream = stream; - return; - } - - - // Create a read stream from a file - // if (file.path) { - // // TODO url - // if (!chunked) { - // fs.readFile(file.path, function(err, data) { - // if (err) { - // stream.emit('error', err); - // } - // if (data) { - // stream.emit('data', data); - // stream.emit('end'); - // } - // }); - - // file.stream = stream; - // return; - // } - - // // TODO don't duplicate this code here, - // // expose a method in File instead - // file.stream = fs.createReadStream(file.path); - // } - } - - - - // before any other listeners are added - emitter.on('abort', function() { - self.readyState = self.DONE; - }); - - - - // Map `error`, `progress`, `load`, and `loadend` - function mapStreamToEmitter(format, encoding) { - var stream = file.stream, - buffers = [], - chunked = self.nodeChunkedEncoding; - - buffers.dataLength = 0; - - stream.on('error', function(err) { - if (self.DONE === self.readyState) { - return; - } - - self.readyState = self.DONE; - self.error = err; - emitter.emit('error', err); - }); - - stream.on('data', function(data) { - if (self.DONE === self.readyState) { - return; - } - - buffers.dataLength += data.length; - buffers.push(data); - - emitter.emit('progress', { - // fs.stat will probably complete before this - // but possibly it will not, hence the check - lengthComputable: (!isNaN(file.size)) ? true : false, - loaded: buffers.dataLength, - total: file.size - }); - - emitter.emit('data', data); - }); - - stream.on('end', function() { - if (self.DONE === self.readyState) { - return; - } - - var data; - - if (buffers.length > 1) { - data = Buffer.concat(buffers); - } else { - data = buffers[0]; - } - - self.readyState = self.DONE; - self.result = mapDataToFormat(file, data, format, encoding); - emitter.emit('load', { - target: { - // non-standard - nodeBufferResult: data, - result: self.result - } - }); - - emitter.emit('loadend'); - }); - } - - - // Abort is overwritten by readAsXyz - self.abort = function() { - if (self.readState == self.DONE) { - return; - } - self.readyState = self.DONE; - emitter.emit('abort'); - }; - - - - // - function mapUserEvents() { - emitter.on('start', function() { - doop(self.onloadstart, arguments); - }); - emitter.on('progress', function() { - doop(self.onprogress, arguments); - }); - emitter.on('error', function(err) { - // TODO translate to FileError - if (self.onerror) { - self.onerror(err); - } else { - if (!emitter.listeners.error || !emitter.listeners.error.length) { - throw err; - } - } - }); - emitter.on('load', function() { - doop(self.onload, arguments); - }); - emitter.on('end', function() { - doop(self.onloadend, arguments); - }); - emitter.on('abort', function() { - doop(self.onabort, arguments); - }); - } - - - - function readFile(_file, format, encoding) { - file = _file; - if (!file || !file.name || !(file.path || file.stream || file.buffer)) { - throw new Error("cannot read as File: " + JSON.stringify(file).slice(0, 1000)); - } - if (0 !== self.readyState) { - console.log("already loading, request to change format ignored"); - return; - } - - // 'process.nextTick' does not ensure order, (i.e. an fs.stat queued later may return faster) - // but `onloadstart` must come before the first `data` event and must be asynchronous. - // Hence we waste a single tick waiting - process.nextTick(function() { - self.readyState = self.LOADING; - emitter.emit('loadstart'); - createFileStream(); - mapStreamToEmitter(format, encoding); - mapUserEvents(); - }); - } - - self.readAsArrayBuffer = function(file) { - readFile(file, 'buffer'); - }; - self.readAsBinaryString = function(file) { - readFile(file, 'binary'); - }; - self.readAsDataURL = function(file) { - readFile(file, 'dataUrl'); - }; - self.readAsText = function(file, encoding) { - readFile(file, 'text', encoding); - }; -} diff --git a/helpers/scanner/file-api.ts b/helpers/scanner/file-api.ts new file mode 100644 index 0000000..788ff1d --- /dev/null +++ b/helpers/scanner/file-api.ts @@ -0,0 +1,337 @@ +import { EventEmitter } from 'events' + +type ReadFormat = 'buffer' | 'binary' | 'dataUrl' | 'text' + +type FileInput = string | Partial +type NodeBuffer = ReturnType + +type FileReaderEventName = 'abort' | 'error' | 'load' | 'loadend' | 'loadstart' | 'progress' + +interface FileReaderProgressEvent { + lengthComputable: boolean + loaded: number + total?: number +} + +export interface FileReaderLoadEvent { + target: { + nodeBufferResult: NodeBuffer + result: NodeBuffer | string + } +} + +interface FileReaderErrorEvent { + target: { + error: Error + } +} + +type FileReaderEventPayload = FileReaderProgressEvent | FileReaderLoadEvent | FileReaderErrorEvent | undefined + +type FileReaderListener = ( event?: FileReaderEventPayload ) => void + +interface NodeFileStat { + mtime: Date + size: number +} + +type NodeFileStream = EventEmitter + +export interface NodeFile { + blob?: Blob + buffer?: NodeBuffer + jsdom?: boolean + lastModifiedDate?: Date + name: string + path?: string + size?: number + stat?: NodeFileStat + stream?: NodeFileStream + type?: string +} + +function invokeIfFunction ( listener: unknown, args: unknown[], context: unknown ) { + if ( typeof listener === 'function' ) { + listener.apply( context, args ) + } +} + +function toDataUrl ( data: NodeBuffer, type?: string ) { + let dataUrl = 'data:' + + if ( type ) { + dataUrl += `${ type };` + } + + if ( /text/i.test( type || '' ) ) { + dataUrl += 'charset=utf-8,' + dataUrl += data.toString( 'utf8' ) + } else { + dataUrl += 'base64,' + dataUrl += data.toString( 'base64' ) + } + + return dataUrl +} + +function mapDataToFormat ( file: NodeFile, data: NodeBuffer, format: ReadFormat, encoding?: BufferEncoding ) { + switch ( format ) { + case 'buffer': + return data + case 'binary': + return data.toString( 'binary' ) + case 'dataUrl': + return toDataUrl( data, file.type ) + case 'text': + return data.toString( encoding || 'utf8' ) + } +} + +export class File implements NodeFile { + blob?: Blob + buffer?: Buffer + jsdom?: boolean + lastModifiedDate?: Date + name: string + path?: string + size?: number + stat?: NodeFileStat + stream?: NodeFileStream + type?: string + + constructor ( input: FileInput ) { + if ( typeof input === 'string' ) { + this.path = input + } else { + Object.assign( this, input ) + } + + if ( !this.name ) { + throw new Error( 'No name' ) + } + + if ( !this.path ) { + if ( this.buffer ) { + this.size = this.buffer.length + } else if ( !this.stream ) { + throw new Error( 'No input, nor stream, nor buffer.' ) + } + + return + } + + if ( !this.jsdom ) { + return + } + } +} + +export class FileReader { + readonly EMPTY = 0 + readonly LOADING = 1 + readonly DONE = 2 + + error?: Error + onabort?: FileReaderListener + onerror?: FileReaderListener + onload?: FileReaderListener + onloadend?: FileReaderListener + onloadstart?: FileReaderListener + onprogress?: FileReaderListener + readyState = this.EMPTY + result?: NodeBuffer | string + + private readonly emitter = new EventEmitter() + private file?: NodeFile + private fileStream?: NodeFileStream + private format?: ReadFormat + private encoding?: BufferEncoding + private readonly registeredEvents = new Set() + + nodeChunkedEncoding = false + + addEventListener ( eventName: FileReaderEventName, callback: FileReaderListener ) { + this.emitter.on( eventName, callback ) + } + + removeEventListener ( eventName: FileReaderEventName, callback: FileReaderListener ) { + this.emitter.removeListener( eventName, callback ) + } + + dispatchEvent ( eventName: FileReaderEventName, payload?: FileReaderEventPayload ) { + this.emitter.emit( eventName, payload ) + } + + on ( eventName: string | symbol, listener: ( ...args: any[] ) => void ) { + this.emitter.on( eventName, listener ) + } + + setNodeChunkedEncoding ( value: boolean ) { + this.nodeChunkedEncoding = value + } + + abort () { + if ( this.readyState === this.DONE ) { + return + } + + this.readyState = this.DONE + this.dispatchEvent( 'abort' ) + } + + readAsArrayBuffer ( file: NodeFile ) { + this.readFile( file, 'buffer' ) + } + + readAsBinaryString ( file: NodeFile ) { + this.readFile( file, 'binary' ) + } + + readAsDataURL ( file: NodeFile ) { + this.readFile( file, 'dataUrl' ) + } + + readAsText ( file: NodeFile, encoding?: BufferEncoding ) { + this.readFile( file, 'text', encoding ) + } + + private createFileStream () { + if ( this.file?.stream ) { + this.fileStream = this.file.stream + return + } + + if ( this.file?.buffer ) { + const stream = new EventEmitter() as NodeFileStream + + process.nextTick( () => { + stream.emit( 'data', this.file!.buffer! ) + stream.emit( 'end' ) + } ) + + this.file!.stream = stream + this.fileStream = stream + } + } + + private registerUserEvents () { + if ( this.registeredEvents.size > 0 ) { + return + } + + const userEvents: Array<[ FileReaderEventName, 'onloadstart' | 'onprogress' | 'onload' | 'onloadend' | 'onabort' ]> = [ + [ 'loadstart', 'onloadstart' ], + [ 'progress', 'onprogress' ], + [ 'load', 'onload' ], + [ 'loadend', 'onloadend' ], + [ 'abort', 'onabort' ] + ] + + for ( const [ eventName, propertyName ] of userEvents ) { + this.emitter.on( eventName, ( event?: FileReaderEventPayload ) => { + invokeIfFunction( this[ propertyName ], [ event ], this ) + } ) + this.registeredEvents.add( eventName ) + } + + this.emitter.on( 'error', ( event?: FileReaderEventPayload ) => { + if ( typeof this.onerror === 'function' ) { + this.onerror( event ) + return + } + + const error = ( event as FileReaderErrorEvent | undefined )?.target.error + + if ( error && this.emitter.listenerCount( 'error' ) <= 1 ) { + throw error + } + } ) + this.registeredEvents.add( 'error' ) + } + + private mapStreamToEmitter () { + const stream = this.fileStream + + if ( !stream || !this.file || !this.format ) { + return + } + + const buffers: NodeBuffer[] = [] + let dataLength = 0 + + stream.on( 'error', ( error: Error ) => { + if ( this.readyState === this.DONE ) { + return + } + + this.readyState = this.DONE + this.error = error + this.dispatchEvent( 'error', { + target: { + error + } + } ) + } ) + + stream.on( 'data', ( data: NodeBuffer ) => { + if ( this.readyState === this.DONE ) { + return + } + + dataLength += data.length + buffers.push( data ) + + this.dispatchEvent( 'progress', { + lengthComputable: !Number.isNaN( this.file?.size ), + loaded: dataLength, + total: this.file?.size + } ) + } ) + + stream.on( 'end', () => { + if ( this.readyState === this.DONE ) { + return + } + + const data = buffers.length > 1 + ? Buffer.concat( buffers as unknown as readonly Uint8Array[] ) as NodeBuffer + : ( buffers[ 0 ] || Buffer.alloc( 0 ) ) + + this.readyState = this.DONE + this.result = mapDataToFormat( this.file!, data, this.format!, this.encoding ) + + const event = { + target: { + nodeBufferResult: data, + result: this.result + } + } + + this.dispatchEvent( 'load', event ) + this.dispatchEvent( 'loadend', event ) + } ) + } + + private readFile ( file: NodeFile, format: ReadFormat, encoding?: BufferEncoding ) { + this.file = file + this.format = format + this.encoding = encoding + + if ( !this.file || !this.file.name || !( this.file.path || this.file.stream || this.file.buffer ) ) { + throw new Error( `cannot read as File: ${ JSON.stringify( this.file ).slice( 0, 1000 ) }` ) + } + + if ( this.readyState !== this.EMPTY ) { + console.log( 'already loading, request to change format ignored' ) + return + } + + process.nextTick( () => { + this.readyState = this.LOADING + this.dispatchEvent( 'loadstart' ) + this.createFileStream() + this.mapStreamToEmitter() + this.registerUserEvents() + } ) + } +} diff --git a/helpers/scanner/parsers/plist.js b/helpers/scanner/parsers/plist.js deleted file mode 100644 index d030d07..0000000 --- a/helpers/scanner/parsers/plist.js +++ /dev/null @@ -1,385 +0,0 @@ -// Adpapted for browser+node from https://github.com/joeferner/node-bplist-parser/blob/master/bplistParser.js -import plainTextPlist from 'plist' -import { Buffer } from 'buffer/index.js' - - -// const fs = require('fs'); -// const bufferApi = require('buffer') -const debug = false - -export const maxObjectSize = 100 * 1000 * 1000; // 100Meg -export const maxObjectCount = 32768; - -// EPOCH = new SimpleDateFormat("yyyy MM dd zzz").parse("2001 01 01 GMT").getTime(); -// ...but that's annoying in a static initializer because it can throw exceptions, ick. -// So we just hardcode the correct value. -const EPOCH = 978307200000; - -// UID object definition -const UID = function(id) { - this.UID = id; -}; - -export function parsePlistBuffer ( fileBuffer , callback) { - return new Promise(function(resolve, reject) { - function tryParseBuffer(buffer) { - let err = null; - let result; - try { - result = parseBuffer(buffer); - resolve(result); - } catch (ex) { - err = ex; - reject(err); - } finally { - if (callback) callback(err, result); - } - } - - return tryParseBuffer( fileBuffer ) - - // if (Buffer.isBuffer(fileNameOrBuffer)) { - - // } - // fs.readFile(fileNameOrBuffer, function(err, data) { - // if (err) { - // reject(err); - // return callback(err); - // } - // tryParseBuffer(data); - // }); - }); -}; - -export function parseFileSync (fileNameOrBuffer) { - // if (!Buffer.isBuffer(fileNameOrBuffer)) { - // fileNameOrBuffer = fs.readFileSync(fileNameOrBuffer); - // } - return parseBuffer(fileNameOrBuffer); -}; - -function parseBuffer ( buffer ) { - // check header - const header = buffer.slice(0, 'bplist'.length).toString('utf8'); - - - - const isPlainTextPlist = header.includes(' maxObjectCount) { - throw new Error("maxObjectCount exceeded"); - } - - // Handle offset table - const offsetTable = []; - - for (let i = 0; i < numObjects; i++) { - const offsetBytes = buffer.slice(offsetTableOffset + i * offsetSize, offsetTableOffset + (i + 1) * offsetSize); - offsetTable[i] = readUInt(offsetBytes, 0); - if (debug) { - console.log("Offset for Object #" + i + " is " + offsetTable[i] + " [" + offsetTable[i].toString(16) + "]"); - } - } - - // Parses an object inside the currently parsed binary property list. - // For the format specification check - // - // Apple's binary property list parser implementation. - function parseObject(tableOffset) { - const offset = offsetTable[tableOffset]; - const type = buffer[offset]; - const objType = (type & 0xF0) >> 4; //First 4 bits - const objInfo = (type & 0x0F); //Second 4 bits - switch (objType) { - case 0x0: - return parseSimple(); - case 0x1: - return parseInteger(); - case 0x8: - return parseUID(); - case 0x2: - return parseReal(); - case 0x3: - return parseDate(); - case 0x4: - return parseData(); - case 0x5: // ASCII - return parsePlistString(); - case 0x6: // UTF-16 - return parsePlistString(true); - case 0xA: - return parseArray(); - case 0xD: - return parseDictionary(); - default: - throw new Error("Unhandled type 0x" + objType.toString(16)); - } - - function parseSimple() { - //Simple - switch (objInfo) { - case 0x0: // null - return null; - case 0x8: // false - return false; - case 0x9: // true - return true; - case 0xF: // filler byte - return null; - default: - throw new Error("Unhandled simple type 0x" + objType.toString(16)); - } - } - - function bufferToHexString(buffer) { - let str = ''; - let i; - for (i = 0; i < buffer.length; i++) { - if (buffer[i] != 0x00) { - break; - } - } - for (; i < buffer.length; i++) { - const part = '00' + buffer[i].toString(16); - str += part.substr(part.length - 2); - } - return str; - } - - function parseInteger() { - const length = Math.pow(2, objInfo); - if (length < maxObjectSize) { - const data = buffer.slice(offset + 1, offset + 1 + length); - if (length === 16) { - const str = bufferToHexString(data); - return BigInt(str, 16); - } - return data.reduce((acc, curr) => { - acc <<= 8; - acc |= curr & 255; - return acc; - }); - } - throw new Error("Too little heap space available! Wanted to read " + length + " bytes, but only " + maxObjectSize + " are available."); - - } - - function parseUID() { - const length = objInfo + 1; - if (length < maxObjectSize) { - return new UID(readUInt(buffer.slice(offset + 1, offset + 1 + length))); - } - throw new Error("Too little heap space available! Wanted to read " + length + " bytes, but only " + maxObjectSize + " are available."); - } - - function parseReal() { - const length = Math.pow(2, objInfo); - if (length < maxObjectSize) { - const realBuffer = buffer.slice(offset + 1, offset + 1 + length); - if (length === 4) { - return realBuffer.readFloatBE(0); - } - if (length === 8) { - return realBuffer.readDoubleBE(0); - } - } else { - throw new Error("Too little heap space available! Wanted to read " + length + " bytes, but only " + maxObjectSize + " are available."); - } - } - - function parseDate() { - if (objInfo != 0x3) { - console.error("Unknown date type :" + objInfo + ". Parsing anyway..."); - } - const dateBuffer = buffer.slice(offset + 1, offset + 9); - return new Date(EPOCH + (1000 * dateBuffer.readDoubleBE(0))); - } - - function parseData() { - let dataoffset = 1; - let length = objInfo; - if (objInfo == 0xF) { - const int_type = buffer[offset + 1]; - const intType = (int_type & 0xF0) / 0x10; - if (intType != 0x1) { - console.error("0x4: UNEXPECTED LENGTH-INT TYPE! " + intType); - } - const intInfo = int_type & 0x0F; - const intLength = Math.pow(2, intInfo); - dataoffset = 2 + intLength; - if (intLength < 3) { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } else { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } - } - if (length < maxObjectSize) { - return buffer.slice(offset + dataoffset, offset + dataoffset + length); - } - throw new Error("Too little heap space available! Wanted to read " + length + " bytes, but only " + maxObjectSize + " are available."); - } - - function parsePlistString(isUtf16) { - isUtf16 = isUtf16 || 0; - let enc = "utf8"; - let length = objInfo; - let stroffset = 1; - if (objInfo == 0xF) { - const int_type = buffer[offset + 1]; - const intType = (int_type & 0xF0) / 0x10; - if (intType != 0x1) { - console.error("UNEXPECTED LENGTH-INT TYPE! " + intType); - } - const intInfo = int_type & 0x0F; - const intLength = Math.pow(2, intInfo); - stroffset = 2 + intLength; - if (intLength < 3) { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } else { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } - } - // length is String length -> to get byte length multiply by 2, as 1 character takes 2 bytes in UTF-16 - length *= (isUtf16 + 1); - if (length < maxObjectSize) { - let plistString = Buffer.from(buffer.slice(offset + stroffset, offset + stroffset + length)); - if (isUtf16) { - plistString = swapBytes(plistString); - enc = "ucs2"; - } - return plistString.toString(enc); - } - throw new Error("Too little heap space available! Wanted to read " + length + " bytes, but only " + maxObjectSize + " are available."); - } - - function parseArray() { - let length = objInfo; - let arrayoffset = 1; - if (objInfo == 0xF) { - const int_type = buffer[offset + 1]; - const intType = (int_type & 0xF0) / 0x10; - if (intType != 0x1) { - console.error("0xa: UNEXPECTED LENGTH-INT TYPE! " + intType); - } - const intInfo = int_type & 0x0F; - const intLength = Math.pow(2, intInfo); - arrayoffset = 2 + intLength; - if (intLength < 3) { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } else { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } - } - if (length * objectRefSize > maxObjectSize) { - throw new Error("Too little heap space available!"); - } - const array = []; - for (let i = 0; i < length; i++) { - const objRef = readUInt(buffer.slice(offset + arrayoffset + i * objectRefSize, offset + arrayoffset + (i + 1) * objectRefSize)); - array[i] = parseObject(objRef); - } - return array; - } - - function parseDictionary() { - let length = objInfo; - let dictoffset = 1; - if (objInfo == 0xF) { - const int_type = buffer[offset + 1]; - const intType = (int_type & 0xF0) / 0x10; - if (intType != 0x1) { - console.error("0xD: UNEXPECTED LENGTH-INT TYPE! " + intType); - } - const intInfo = int_type & 0x0F; - const intLength = Math.pow(2, intInfo); - dictoffset = 2 + intLength; - if (intLength < 3) { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } else { - length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); - } - } - if (length * 2 * objectRefSize > maxObjectSize) { - throw new Error("Too little heap space available!"); - } - if (debug) { - console.log("Parsing dictionary #" + tableOffset); - } - const dict = {}; - for (let i = 0; i < length; i++) { - const keyRef = readUInt(buffer.slice(offset + dictoffset + i * objectRefSize, offset + dictoffset + (i + 1) * objectRefSize)); - const valRef = readUInt(buffer.slice(offset + dictoffset + (length * objectRefSize) + i * objectRefSize, offset + dictoffset + (length * objectRefSize) + (i + 1) * objectRefSize)); - const key = parseObject(keyRef); - const val = parseObject(valRef); - if (debug) { - console.log(" DICT #" + tableOffset + ": Mapped " + key + " to " + val); - } - dict[key] = val; - } - return dict; - } - } - - return parseObject(topObject) -}; - -function readUInt(buffer, start) { - start = start || 0; - - let l = 0; - for (let i = start; i < buffer.length; i++) { - l <<= 8; - l |= buffer[i] & 0xFF; - } - return l; -} - -// we're just going to toss the high order bits because javascript doesn't have 64-bit ints -function readUInt64BE(buffer, start) { - const data = buffer.slice(start, start + 8); - return data.readUInt32BE(4, 8); -} - -function swapBytes(buffer) { - const len = buffer.length; - for (let i = 0; i < len; i += 2) { - const a = buffer[i]; - buffer[i] = buffer[i + 1]; - buffer[i + 1] = a; - } - return buffer; -} diff --git a/helpers/scanner/parsers/plist.ts b/helpers/scanner/parsers/plist.ts new file mode 100644 index 0000000..5132a8a --- /dev/null +++ b/helpers/scanner/parsers/plist.ts @@ -0,0 +1,384 @@ +// Adapted for browser+node from https://github.com/joeferner/node-bplist-parser/blob/master/bplistParser.js +import plainTextPlist from 'plist' +import { Buffer } from 'buffer/index.js' + +const debug = false + +export const maxObjectSize = 100 * 1000 * 1000 +export const maxObjectCount = 32768 + +const EPOCH = 978307200000 +type NodeBuffer = ReturnType + +export class UID { + UID: number + + constructor ( id: number ) { + this.UID = id + } +} + +type PlistValue = + | null + | boolean + | number + | bigint + | string + | Date + | Buffer + | UID + | PlistValue[] + | { [ key: string ]: PlistValue } + +export function parsePlistBuffer ( + fileBuffer: Uint8Array | NodeBuffer, + callback?: ( error: Error | null, result?: PlistValue ) => void +) { + return new Promise( ( resolve, reject ) => { + function tryParseBuffer ( buffer: Uint8Array | NodeBuffer ) { + let error: Error | null = null + let result: PlistValue | undefined + + try { + result = parseBuffer( buffer ) + resolve( result ) + } catch ( caughtError ) { + error = caughtError as Error + reject( error ) + } finally { + if ( callback ) { + callback( error, result ) + } + } + } + + tryParseBuffer( fileBuffer ) + } ) +} + +export function parseFileSync ( fileNameOrBuffer: Uint8Array | NodeBuffer ) { + return parseBuffer( fileNameOrBuffer ) +} + +function parseBuffer ( inputBuffer: Uint8Array | NodeBuffer ): PlistValue { + const buffer = Buffer.from( inputBuffer ) + const header = buffer.slice( 0, 'bplist'.length ).toString( 'utf8' ) + const isPlainTextPlist = header.includes( ' maxObjectCount ) { + throw new Error( 'maxObjectCount exceeded' ) + } + + const offsetTable: number[] = [] + + for ( let index = 0; index < numObjects; index += 1 ) { + const offsetBytes = buffer.slice( + offsetTableOffset + index * offsetSize, + offsetTableOffset + ( index + 1 ) * offsetSize + ) + + offsetTable[ index ] = readUInt( offsetBytes, 0 ) + + if ( debug ) { + console.log( `Offset for Object #${ index } is ${ offsetTable[ index ] } [${ offsetTable[ index ].toString( 16 ) }]` ) + } + } + + function parseObject ( tableOffset: number ): PlistValue { + const offset = offsetTable[ tableOffset ] + const type = buffer[ offset ] + const objType = ( type & 0xF0 ) >> 4 + const objInfo = ( type & 0x0F ) + + switch ( objType ) { + case 0x0: + return parseSimple() + case 0x1: + return parseInteger() + case 0x8: + return parseUID() + case 0x2: + return parseReal() + case 0x3: + return parseDate() + case 0x4: + return parseData() + case 0x5: + return parsePlistString() + case 0x6: + return parsePlistString( true ) + case 0xA: + return parseArray() + case 0xD: + return parseDictionary() + default: + throw new Error( `Unhandled type 0x${ objType.toString( 16 ) }` ) + } + + function parseSimple (): PlistValue { + switch ( objInfo ) { + case 0x0: + return null + case 0x8: + return false + case 0x9: + return true + case 0xF: + return null + default: + throw new Error( `Unhandled simple type 0x${ objType.toString( 16 ) }` ) + } + } + + function bufferToHexString ( inputBuffer: NodeBuffer ) { + let result = '' + let index = 0 + + for ( ; index < inputBuffer.length; index += 1 ) { + if ( inputBuffer[ index ] !== 0x00 ) { + break + } + } + + for ( ; index < inputBuffer.length; index += 1 ) { + const part = `00${ inputBuffer[ index ].toString( 16 ) }` + result += part.slice( part.length - 2 ) + } + + return result + } + + function parseInteger (): PlistValue { + const length = Math.pow( 2, objInfo ) + + if ( length >= maxObjectSize ) { + throw new Error( `Too little heap space available! Wanted to read ${ length } bytes, but only ${ maxObjectSize } are available.` ) + } + + const data = buffer.slice( offset + 1, offset + 1 + length ) + + if ( length === 16 ) { + const hex = bufferToHexString( data ) + return BigInt( `0x${ hex }` ) + } + + return data.reduce( ( accumulator, currentValue ) => { + accumulator <<= 8 + accumulator |= currentValue & 255 + return accumulator + }, 0 ) + } + + function parseUID (): PlistValue { + const length = objInfo + 1 + + if ( length >= maxObjectSize ) { + throw new Error( `Too little heap space available! Wanted to read ${ length } bytes, but only ${ maxObjectSize } are available.` ) + } + + return new UID( readUInt( buffer.slice( offset + 1, offset + 1 + length ) ) ) + } + + function parseReal (): PlistValue { + const length = Math.pow( 2, objInfo ) + + if ( length >= maxObjectSize ) { + throw new Error( `Too little heap space available! Wanted to read ${ length } bytes, but only ${ maxObjectSize } are available.` ) + } + + const realBuffer = buffer.slice( offset + 1, offset + 1 + length ) + + if ( length === 4 ) { + return realBuffer.readFloatBE( 0 ) + } + + if ( length === 8 ) { + return realBuffer.readDoubleBE( 0 ) + } + + throw new Error( `Unsupported real length ${ length }` ) + } + + function parseDate (): PlistValue { + if ( objInfo !== 0x3 ) { + console.error( `Unknown date type :${ objInfo }. Parsing anyway...` ) + } + + const dateBuffer = buffer.slice( offset + 1, offset + 9 ) + return new Date( EPOCH + ( 1000 * dateBuffer.readDoubleBE( 0 ) ) ) + } + + function readLength ( kind: string ) { + let dataOffset = 1 + let length = objInfo + + if ( objInfo === 0xF ) { + const intTypeByte = buffer[ offset + 1 ] + const intType = ( intTypeByte & 0xF0 ) / 0x10 + + if ( intType !== 0x1 ) { + console.error( `${ kind }: UNEXPECTED LENGTH-INT TYPE! ${ intType }` ) + } + + const intInfo = intTypeByte & 0x0F + const intLength = Math.pow( 2, intInfo ) + dataOffset = 2 + intLength + length = readUInt( buffer.slice( offset + 2, offset + 2 + intLength ) ) + } + + return { + dataOffset, + length + } + } + + function parseData (): PlistValue { + const { dataOffset, length } = readLength( '0x4' ) + + if ( length >= maxObjectSize ) { + throw new Error( `Too little heap space available! Wanted to read ${ length } bytes, but only ${ maxObjectSize } are available.` ) + } + + return buffer.slice( offset + dataOffset, offset + dataOffset + length ) + } + + function parsePlistString ( isUtf16 = false ): PlistValue { + let encoding: BufferEncoding = 'utf8' + const { dataOffset, length: rawLength } = readLength( 'string' ) + const length = rawLength * ( isUtf16 ? 2 : 1 ) + + if ( length >= maxObjectSize ) { + throw new Error( `Too little heap space available! Wanted to read ${ length } bytes, but only ${ maxObjectSize } are available.` ) + } + + let plistString = Buffer.from( buffer.slice( offset + dataOffset, offset + dataOffset + length ) ) + + if ( isUtf16 ) { + plistString = swapBytes( plistString ) + encoding = 'ucs2' + } + + return plistString.toString( encoding ) + } + + function parseArray (): PlistValue { + const { dataOffset, length } = readLength( '0xa' ) + + if ( length * objectRefSize > maxObjectSize ) { + throw new Error( 'Too little heap space available!' ) + } + + const array: PlistValue[] = [] + + for ( let index = 0; index < length; index += 1 ) { + const objectRef = readUInt( + buffer.slice( + offset + dataOffset + index * objectRefSize, + offset + dataOffset + ( index + 1 ) * objectRefSize + ) + ) + + array[ index ] = parseObject( objectRef ) + } + + return array + } + + function parseDictionary (): PlistValue { + const { dataOffset, length } = readLength( '0xd' ) + + if ( length * 2 * objectRefSize > maxObjectSize ) { + throw new Error( 'Too little heap space available!' ) + } + + const dictionary: Record = {} + + for ( let index = 0; index < length; index += 1 ) { + const keyRef = readUInt( + buffer.slice( + offset + dataOffset + index * objectRefSize, + offset + dataOffset + ( index + 1 ) * objectRefSize + ) + ) + const valueRef = readUInt( + buffer.slice( + offset + dataOffset + length * objectRefSize + index * objectRefSize, + offset + dataOffset + length * objectRefSize + ( index + 1 ) * objectRefSize + ) + ) + const key = parseObject( keyRef ) + + if ( typeof key !== 'string' ) { + throw new Error( 'Dictionary key is not a string' ) + } + + dictionary[ key ] = parseObject( valueRef ) + } + + return dictionary + } + } + + return parseObject( topObject ) +} + +function readUInt64BE ( buffer: NodeBuffer, offset: number ) { + const data = buffer.slice( offset, offset + 8 ) + + return data.reduce( ( accumulator, currentValue ) => { + accumulator <<= 8 + accumulator |= currentValue & 0xff + return accumulator + }, 0 ) +} + +function readUInt ( buffer: NodeBuffer, start = 0 ) { + return buffer.slice( start ).reduce( ( accumulator, currentValue ) => { + accumulator <<= 8 + accumulator |= currentValue & 0xff + return accumulator + }, 0 ) +} + +function swapBytes ( buffer: NodeBuffer ) { + const length = buffer.length + + if ( length % 2 !== 0 ) { + throw new Error( 'Buffer length must be even' ) + } + + for ( let index = 0; index < length; index += 2 ) { + const currentValue = buffer[ index ] + buffer[ index ] = buffer[ index + 1 ] + buffer[ index + 1 ] = currentValue + } + + return buffer +} diff --git a/helpers/scanner/scan.ts b/helpers/scanner/scan.ts index bbe494d..6015c6b 100644 --- a/helpers/scanner/scan.ts +++ b/helpers/scanner/scan.ts @@ -2,10 +2,11 @@ import { Buffer } from 'buffer/index.js' import prettyBytes from 'pretty-bytes' import * as zip from '@zip.js/zip.js' -import * as FileApi from './file-api.js' +import * as FileApi from './file-api' +import type { NodeFile } from './file-api' import { isNonEmptyString, isString } from '../check-types.js' import { extractMachoMeta } from './parsers/macho.js' -import { parsePlistBuffer } from './parsers/plist.js' +import { parsePlistBuffer } from './parsers/plist' zip.configure({ useWebWorkers: !import.meta.env.SSR @@ -89,7 +90,7 @@ interface ScanFileEntry { interface ScanMachoFileInstance { blob?: Blob - buffer: Buffer + buffer: NodeFile['buffer'] name: string type: string } @@ -390,10 +391,10 @@ export class AppScan { const bundleExecutableUint8Array = await this.readFileEntryData>( fileEntry, zip.Uint8ArrayWriter as new () => InstanceType ) as Uint8Array const machoFileInstance = new FileApi.File({ - buffer: Buffer.from( bundleExecutableUint8Array ), + buffer: Buffer.from( bundleExecutableUint8Array ) as unknown as NodeFile['buffer'], name: this.bundleExecutable.filename, type: 'application/x-mach-binary' - }) as ScanMachoFileInstance + }) as unknown as ScanMachoFileInstance machoFileInstance.blob = await this.readFileEntryData>( fileEntry, zip.BlobWriter as new () => InstanceType ) as Blob diff --git a/test/scanner/file-api.test.ts b/test/scanner/file-api.test.ts new file mode 100644 index 0000000..903b9a7 --- /dev/null +++ b/test/scanner/file-api.test.ts @@ -0,0 +1,72 @@ +import { Buffer } from 'buffer' + +import { + describe, + expect, + it +} from 'vitest' + +import { + File, + FileReader, + type FileReaderLoadEvent +} from '~/helpers/scanner/file-api' + +describe( 'scanner file api shim', () => { + it( 'constructs a file from a buffer payload', () => { + const file = new File({ + buffer: Buffer.from( 'hello world', 'utf8' ), + name: 'hello.txt', + type: 'text/plain' + }) + + expect( file.name ).toBe( 'hello.txt' ) + expect( file.type ).toBe( 'text/plain' ) + expect( file.size ).toBe( 11 ) + } ) + + it( 'reads text content through the node FileReader shim', async () => { + const file = new File({ + buffer: Buffer.from( 'scanner-text', 'utf8' ), + name: 'scanner.txt', + type: 'text/plain' + }) + const reader = new FileReader() + + const loadedText = await new Promise( ( resolve, reject ) => { + reader.onerror = reject + reader.onload = event => { + const loadEvent = event as FileReaderLoadEvent + + resolve( String( loadEvent.target.result ) ) + } + + reader.readAsText( file ) + } ) + + expect( loadedText ).toBe( 'scanner-text' ) + } ) + + it( 'reads binary content through the node FileReader shim', async () => { + const file = new File({ + buffer: Buffer.from( [ 0xde, 0xad, 0xbe, 0xef ] ), + name: 'scanner.bin', + type: 'application/octet-stream' + }) + const reader = new FileReader() + + const loadedBuffer = await new Promise( ( resolve, reject ) => { + reader.onerror = reject + reader.onload = event => { + const loadEvent = event as FileReaderLoadEvent + + resolve( loadEvent.target.nodeBufferResult ) + } + + reader.readAsArrayBuffer( file ) + } ) + + expect( Buffer.isBuffer( loadedBuffer ) ).toBe( true ) + expect( loadedBuffer.toString( 'hex' ) ).toBe( 'deadbeef' ) + } ) +} ) diff --git a/test/scanner/plist.test.ts b/test/scanner/plist.test.ts new file mode 100644 index 0000000..529790c --- /dev/null +++ b/test/scanner/plist.test.ts @@ -0,0 +1,52 @@ +import { Buffer } from 'buffer' + +import { + describe, + expect, + it, + vi +} from 'vitest' + +import { + parseFileSync, + parsePlistBuffer +} from '~/helpers/scanner/parsers/plist' + +type ParsedPlist = Record + +const xmlPlist = Buffer.from( [ + '', + '', + '', + '', + ' CFBundleExecutable', + ' Playwright Native App', + ' CFBundleIdentifier', + ' com.doesitarm.playwright-native-app', + '', + '' +].join( '\n' ), 'utf8' ) + +describe( 'plist parser', () => { + it( 'parses xml plist buffers asynchronously', async () => { + const callback = vi.fn() + const plist = await parsePlistBuffer( xmlPlist as any, callback ) as ParsedPlist + + expect( plist.CFBundleExecutable ).toBe( 'Playwright Native App' ) + expect( plist.CFBundleIdentifier ).toBe( 'com.doesitarm.playwright-native-app' ) + expect( callback ).toHaveBeenCalledWith( null, plist ) + } ) + + it( 'parses xml plist buffers synchronously', () => { + const plist = parseFileSync( xmlPlist as any ) as ParsedPlist + + expect( plist.CFBundleExecutable ).toBe( 'Playwright Native App' ) + expect( plist.CFBundleIdentifier ).toBe( 'com.doesitarm.playwright-native-app' ) + } ) + + it( 'rejects invalid plist data', async () => { + await expect( parsePlistBuffer( Buffer.from( 'not-a-plist', 'utf8' ) as any ) ) + .rejects + .toThrow( /Invalid binary plist/i ) + } ) +} )