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:
ThatGuySam 2026-04-04 18:13:32 -05:00
parent 6d858d2a19
commit cd41143f0d
7 changed files with 851 additions and 723 deletions

View file

@ -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
View 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()
} )
}
}

View file

@ -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;
}

View 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
}

View file

@ -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