mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-15 06:35:20 -07:00
refactor(scanner): type plist and file-api helpers
Convert the scanner's plist parser and Node-style file shim to TypeScript and add small unit tests so common parser and file-reader failures are caught before we need to lean on Playwright or the higher-level scanner test. Constraint: Must preserve current scanner behavior while tightening the lowest-level helper surface Rejected: Jump straight to Mach-O parser conversion | harder to isolate regressions without first proving the smaller helper-test pattern Confidence: high Scope-risk: narrow Reversibility: clean Directive: Add small module-level regression tests for repeatable scanner breakage before expanding browser coverage Tested: pnpm run typecheck; pnpm exec vitest run test/scanner/plist.test.ts test/scanner/file-api.test.ts test/scanner/client.test.ts; pnpm run test; pnpm run test:browser Not-tested: Production deploy behavior prior to push
This commit is contained in:
parent
6d858d2a19
commit
cd41143f0d
7 changed files with 851 additions and 723 deletions
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
337
helpers/scanner/file-api.ts
Normal file
337
helpers/scanner/file-api.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import { EventEmitter } from 'events'
|
||||
|
||||
type ReadFormat = 'buffer' | 'binary' | 'dataUrl' | 'text'
|
||||
|
||||
type FileInput = string | Partial<NodeFile>
|
||||
type NodeBuffer = ReturnType<typeof Buffer.from>
|
||||
|
||||
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<FileReaderEventName>()
|
||||
|
||||
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()
|
||||
} )
|
||||
}
|
||||
}
|
||||
|
|
@ -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('<?xml')
|
||||
|
||||
if ( isPlainTextPlist ) {
|
||||
// console.log( 'isPlainTextPlist', isPlainTextPlist )
|
||||
|
||||
return plainTextPlist.parse( buffer.toString('utf8') )
|
||||
}
|
||||
|
||||
if (header !== 'bplist') {
|
||||
throw new Error("Invalid binary plist. Expected 'bplist' at offset 0.");
|
||||
}
|
||||
|
||||
// Handle trailer, last 32 bytes of the file
|
||||
const trailer = buffer.slice(buffer.length - 32, buffer.length);
|
||||
// 6 null bytes (index 0 to 5)
|
||||
const offsetSize = trailer.readUInt8(6);
|
||||
if (debug) {
|
||||
console.log("offsetSize: " + offsetSize);
|
||||
}
|
||||
const objectRefSize = trailer.readUInt8(7);
|
||||
if (debug) {
|
||||
console.log("objectRefSize: " + objectRefSize);
|
||||
}
|
||||
const numObjects = readUInt64BE(trailer, 8);
|
||||
if (debug) {
|
||||
console.log("numObjects: " + numObjects);
|
||||
}
|
||||
const topObject = readUInt64BE(trailer, 16);
|
||||
if (debug) {
|
||||
console.log("topObject: " + topObject);
|
||||
}
|
||||
const offsetTableOffset = readUInt64BE(trailer, 24);
|
||||
if (debug) {
|
||||
console.log("offsetTableOffset: " + offsetTableOffset);
|
||||
}
|
||||
|
||||
if (numObjects > 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
|
||||
// <a href="https://www.opensource.apple.com/source/CF/CF-635/CFBinaryPList.c">
|
||||
// Apple's binary property list parser implementation</a>.
|
||||
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;
|
||||
}
|
||||
384
helpers/scanner/parsers/plist.ts
Normal file
384
helpers/scanner/parsers/plist.ts
Normal file
|
|
@ -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<typeof Buffer.from>
|
||||
|
||||
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<PlistValue>( ( 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( '<?xml' )
|
||||
|
||||
if ( isPlainTextPlist ) {
|
||||
return plainTextPlist.parse( buffer.toString( 'utf8' ) ) as PlistValue
|
||||
}
|
||||
|
||||
if ( header !== 'bplist' ) {
|
||||
throw new Error( "Invalid binary plist. Expected 'bplist' at offset 0." )
|
||||
}
|
||||
|
||||
const trailer = buffer.slice( buffer.length - 32, buffer.length )
|
||||
const offsetSize = trailer.readUInt8( 6 )
|
||||
|
||||
if ( debug ) {
|
||||
console.log( `offsetSize: ${ offsetSize }` )
|
||||
}
|
||||
|
||||
const objectRefSize = trailer.readUInt8( 7 )
|
||||
const numObjects = readUInt64BE( trailer, 8 )
|
||||
const topObject = readUInt64BE( trailer, 16 )
|
||||
const offsetTableOffset = readUInt64BE( trailer, 24 )
|
||||
|
||||
if ( debug ) {
|
||||
console.log( `objectRefSize: ${ objectRefSize }` )
|
||||
console.log( `numObjects: ${ numObjects }` )
|
||||
console.log( `topObject: ${ topObject }` )
|
||||
console.log( `offsetTableOffset: ${ offsetTableOffset }` )
|
||||
}
|
||||
|
||||
if ( numObjects > 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<string, PlistValue> = {}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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<InstanceType<typeof zip.Uint8ArrayWriter>>( fileEntry, zip.Uint8ArrayWriter as new () => InstanceType<typeof zip.Uint8ArrayWriter> ) 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<InstanceType<typeof zip.BlobWriter>>( fileEntry, zip.BlobWriter as new () => InstanceType<typeof zip.BlobWriter> ) as Blob
|
||||
|
||||
|
|
|
|||
72
test/scanner/file-api.test.ts
Normal file
72
test/scanner/file-api.test.ts
Normal file
|
|
@ -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<string>( ( 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<Buffer>( ( 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' )
|
||||
} )
|
||||
} )
|
||||
52
test/scanner/plist.test.ts
Normal file
52
test/scanner/plist.test.ts
Normal file
|
|
@ -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<string, string>
|
||||
|
||||
const xmlPlist = Buffer.from( [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>CFBundleExecutable</key>',
|
||||
' <string>Playwright Native App</string>',
|
||||
' <key>CFBundleIdentifier</key>',
|
||||
' <string>com.doesitarm.playwright-native-app</string>',
|
||||
'</dict>',
|
||||
'</plist>'
|
||||
].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 )
|
||||
} )
|
||||
} )
|
||||
Loading…
Add table
Add a link
Reference in a new issue