Skip to content

Instantly share code, notes, and snippets.

@manekinekko
Last active October 21, 2022 09:40
Show Gist options
  • Save manekinekko/e897e5025048cfa10fedcfd6317aab5d to your computer and use it in GitHub Desktop.
Save manekinekko/e897e5025048cfa10fedcfd6317aab5d to your computer and use it in GitHub Desktop.
Apple's Binary Property List (bplist) parser in #JavaScript for the browser.
// 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;
}
}
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--------------------
@bertrandg
Copy link

Thanks for this,
I used it to decode webloc files created from safari in a webapp! 👌

@bertrandg
Copy link

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