doesitarm/helpers/scanner/parsers/plist.ts
ThatGuySam cd41143f0d 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
2026-04-04 18:13:32 -05:00

384 lines
12 KiB
TypeScript

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