Last active
November 14, 2024 18:03
-
-
Save manekinekko/e897e5025048cfa10fedcfd6317aab5d to your computer and use it in GitHub Desktop.
Apple's Binary Property List (bplist) parser in #JavaScript for the browser.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// const Buffer = require('buffer/').Buffer; | |
import { Injectable } from '@angular/core'; | |
// Ported to browser from https://githuBuffer.com/joeferner/node-bplist-parser/blob/master/bplistParser.js | |
// Inspired by http://code.google.com/p/plist/source/browse/trunk/src/com/dd/plist/BinaryPropertyListParser.java | |
const bigInt = require('big-integer'); | |
const Buffer = require('bops'); | |
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. | |
export const EPOCH = 978307200000; | |
// UID object definition | |
export class UID { | |
constructor(private id: number) {} | |
} | |
@Injectable({ | |
providedIn: 'root' | |
}) | |
export class BinaryPlistParserService { | |
private debug = true; | |
parse64Content(base64Content: string) { | |
const raw = atob(base64Content); | |
const rawLength = raw.length; | |
const array = new Buffer(rawLength); | |
for (let i = 0; i < rawLength; i++) { | |
array[i] = raw.charCodeAt(i); | |
} | |
return this.parseBuffer(array); | |
} | |
private parseBuffer(buffer: Uint8Array) { | |
// check header | |
const l = 'bplist'.length; | |
const header = Buffer.to(buffer.slice(0, l)) as string; | |
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 = Buffer.readUInt8(trailer, 6); | |
if (this.debug) { | |
console.log('offsetSize: ' + offsetSize); | |
} | |
const objectRefSize = Buffer.readUInt8(trailer, 7); | |
if (this.debug) { | |
console.log('objectRefSize: ' + objectRefSize); | |
} | |
const numObjects = this.readUInt64BE(trailer, 8); | |
if (this.debug) { | |
console.log('numObjects: ' + numObjects); | |
} | |
const topObject = this.readUInt64BE(trailer, 16); | |
if (this.debug) { | |
console.log('topObject: ' + topObject); | |
} | |
const offsetTableOffset = this.readUInt64BE(trailer, 24); | |
if (this.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] = this.readUInt(offsetBytes, 0); | |
if (this.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="http://www.opensource.apple.com/source/CF/CF-635/CFBinaryPList.c"> | |
// Apple's binary property list parser implementation</a>. | |
const 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 | |
const 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)); | |
} | |
}; | |
const 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; | |
}; | |
const parseInteger = () => { | |
const length = Math.pow(2, objInfo); | |
if (length > 4) { | |
const data = buffer.slice(offset + 1, offset + 1 + length); | |
const str = bufferToHexString(data); | |
return bigInt(str, 16); | |
} | |
if (length < maxObjectSize) { | |
return this.readUInt(buffer.slice(offset + 1, offset + 1 + length)); | |
} else { | |
throw new Error( | |
'Too little heap space available! Wanted to read ' + length + ' bytes, but only ' + maxObjectSize + ' are available.' | |
); | |
} | |
}; | |
const parseUID = () => { | |
const length = objInfo + 1; | |
if (length < maxObjectSize) { | |
return new UID(this.readUInt(buffer.slice(offset + 1, offset + 1 + length))); | |
} else { | |
throw new Error( | |
'To little heap space available! Wanted to read ' + length + ' bytes, but only ' + maxObjectSize + ' are available.' | |
); | |
} | |
}; | |
const parseReal = () => { | |
const length = Math.pow(2, objInfo); | |
if (length < maxObjectSize) { | |
const realBuffer = buffer.slice(offset + 1, offset + 1 + length); | |
if (length === 4) { | |
return Buffer.readFloatBE(realBuffer, 0); | |
} else if (length === 8) { | |
return Buffer.readDoubleBE(realBuffer, 0); | |
} | |
} else { | |
throw new Error( | |
'To little heap space available! Wanted to read ' + length + ' bytes, but only ' + maxObjectSize + ' are available.' | |
); | |
} | |
}; | |
const 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 * Buffer.readDoubleBE(dateBuffer, 0)); | |
}; | |
const 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 = this.readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); | |
} else { | |
length = this.readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); | |
} | |
} | |
if (length < maxObjectSize) { | |
return buffer.slice(offset + dataoffset, offset + dataoffset + length); | |
} else { | |
throw new Error( | |
'To little heap space available! Wanted to read ' + length + ' bytes, but only ' + maxObjectSize + ' are available.' | |
); | |
} | |
}; | |
const 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 = this.readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); | |
} else { | |
length = this.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.to(buffer.slice(offset + stroffset, offset + stroffset + length)); | |
if (isUtf16) { | |
plistString = this.swapBytes(plistString); | |
enc = 'ucs2'; | |
} | |
return plistString.toString(enc); | |
} else { | |
throw new Error( | |
'To little heap space available! Wanted to read ' + length + ' bytes, but only ' + maxObjectSize + ' are available.' | |
); | |
} | |
}; | |
const 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 = this.readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); | |
} else { | |
length = this.readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); | |
} | |
} | |
if (length * objectRefSize > maxObjectSize) { | |
throw new Error('To little heap space available!'); | |
} | |
const array = []; | |
for (let i = 0; i < length; i++) { | |
const objRef = this.readUInt( | |
buffer.slice(offset + arrayoffset + i * objectRefSize, offset + arrayoffset + (i + 1) * objectRefSize) | |
); | |
array[i] = parseObject(objRef); | |
} | |
return array; | |
}; | |
const 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 = this.readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); | |
} else { | |
length = this.readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); | |
} | |
} | |
if (length * 2 * objectRefSize > maxObjectSize) { | |
throw new Error('To little heap space available!'); | |
} | |
if (this.debug) { | |
console.log('Parsing dictionary #' + tableOffset); | |
} | |
const dict = {}; | |
for (let i = 0; i < length; i++) { | |
const keyRef = this.readUInt( | |
buffer.slice(offset + dictoffset + i * objectRefSize, offset + dictoffset + (i + 1) * objectRefSize) | |
); | |
const valRef = this.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 (this.debug) { | |
console.log(' DICT #' + tableOffset + ': Mapped ' + key + ' to ' + val); | |
} | |
dict[key] = val; | |
} | |
return dict; | |
}; | |
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)); | |
} | |
}; | |
return [parseObject(topObject)]; | |
} | |
private readUInt(buffer: Uint8Array, start?: number) { | |
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 | |
private readUInt64BE(buffer: Uint8Array, start: number) { | |
const data = buffer.slice(start, start + 8); | |
return Buffer.readUInt32BE(data, 4, 8 as any); | |
} | |
private swapBytes(buffer: number[]) { | |
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Raw data in Base64: | |
----------COPY BELOW------------ | |
YnBsaXN0MDDUAQIDBAUIKClUJHRvcFgkb2JqZWN0c1gkdmVyc2lvblkkYXJjaGl2ZXLRBgdUcm9vdIABqQkKDxkaGxwdJFUkbnVsbNILDA0OViRjbGFzc18QGk5TRm9udERlc2NyaXB0b3JBdHRyaWJ1dGVzgAiAAtMQCxESFRZaTlMub2JqZWN0c1dOUy5rZXlzohMUgAWABoAHohcYgAOABF8QE05TRm9udE5hbWVBdHRyaWJ1dGVfEBNOU0ZvbnRTaXplQXR0cmlidXRlXU1lbmxvLVJlZ3VsYXIiQUAAANIeHyAhWCRjbGFzc2VzWiRjbGFzc25hbWWjISIjXxATTlNNdXRhYmxlRGljdGlvbmFyeVxOU0RpY3Rpb25hcnlYTlNPYmplY3TSHh8lJ6ImI18QEE5TRm9udERlc2NyaXB0b3JfEBBOU0ZvbnREZXNjcmlwdG9yEgABhqBfEA9OU0tleWVkQXJjaGl2ZXIACAARABYAHwAoADIANQA6ADwARgBMAFEAWAB1AHcAeQCAAIsAkwCWAJgAmgCcAJ8AoQCjALkAzwDdAOIA5wDwAPsA/wEVASIBKwEwATMBRgFZAV4AAAAAAAACAQAAAAAAAAAqAAAAAAAAAAAAAAAAAAABcA== | |
----------END-------------------- | |
Base64 -> Bin: | |
----------COPY BELOW------------ | |
bplist00ҁ()T$topX$objectsX$versionY$archiverφTroot© | |
$U$nullЋ | |
V$class_NSFontDescriptorAttributesѐZNS.objectsWNS.keys¢¢_NSFontNameAttribute_NSFontSizeAttribute]Menlo-Regular"A@О !X$classesZ$classname£"#_NSMutableDictionary\NSDictionaryXNSObjectО%'¢#_NSFontDescriptor_NSFontDescriptor_NSKeyedArchiver(25:<FLQXuwy¡£¹̀ۀg偰쿕"+03FY^*p | |
----------END-------------------- |
I just added this function to the service to work directly with binary from webloc loaded file:
parseContent(content: ArrayBuffer) {
const buffer = new Uint8Array(content);
return this.parseBuffer(buffer);
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks for this,
I used it to decode webloc files created from safari in a webapp! 👌