// Property lists without dependencies: write XML plists (ExportOptions.plist), read XML and binary plists (a source or // IPA Info.plist, the plist inside a .mobileprovision). Only the types those files use: dict, array, string, integer, // real, true / false, date (as an ISO string) and data (as a Buffer). Pure. export type PlistValue = string | number | boolean | Buffer | PlistValue[] | { [k: string]: PlistValue }; const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>'); function xmlValue(v: PlistValue, ind: string): string { if (typeof v === 'string') return `${ind}${esc(v)}`; if (typeof v === 'boolean') return `${ind}<${v}/>`; if (typeof v === 'number') return Number.isInteger(v) ? `${ind}${v}` : `${ind}${v}`; if (Buffer.isBuffer(v)) return `${ind}${v.toString('base64')}`; if (Array.isArray(v)) return v.length ? `${ind}\n${v.map(x => xmlValue(x, ind + '\t')).join('\n')}\n${ind}` : `${ind}`; const keys = Object.keys(v).filter(k => v[k] !== undefined); if (!keys.length) return `${ind}`; return `${ind}\n${keys.map(k => `${ind}\t${esc(k)}\n${xmlValue(v[k]!, ind + '\t')}`).join('\n')}\n${ind}`; } /** XML plist text, the format Xcode writes. */ export function toXmlPlist(v: PlistValue): string { return '\n\n' + `\n${xmlValue(v, '')}\n\n`; } const unesc = (s: string): string => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, '&'); /** Parses an XML plist (tolerant of comments and whitespace). Throws on anything it does not understand. */ export function parseXmlPlist(text: string): PlistValue { if (text.startsWith('bplist')) throw new Error('binary plist: read the file as bytes (parsePlist(Buffer)) or convert it with `plutil -convert xml1`'); const body = /]*>([\s\S]*)<\/plist>/.exec(text)?.[1] ?? text.replace(/<\?xml[^>]*\?>|]*>/g, ''); if (!/<(dict|array|string|integer|real|true|false|date|data)\b/.test(body)) throw new Error('not an XML plist'); // tags may carry attributes or a space before "/>" (hand-edited files) const toks = [...body.replace(//g, '').matchAll(/<(\/?)([a-zA-Z]+)(?:\s[^>]*?)?\s*(\/?)>|([^<]+)/g)]; let i = 0; const skipWs = () => { while (i < toks.length && toks[i]![4] !== undefined && !toks[i]![4]!.trim()) i++; }; const text_ = (tag: string): string => { let s = ''; while (i < toks.length && !(toks[i]![1] === '/' && toks[i]![2] === tag)) { s += toks[i]![4] ?? ''; i++; } i++; return unesc(s); }; function value(): PlistValue { skipWs(); const t = toks[i++]; if (!t || t[1]) throw new Error('plist: unexpected end or closing tag'); const tag = t[2]!, selfClose = t[3] === '/'; if (tag === 'true' || tag === 'false') { if (!selfClose) text_(tag); return tag === 'true'; } if (selfClose) return tag === 'array' ? [] : tag === 'dict' ? {} : tag === 'data' ? Buffer.alloc(0) : ''; if (tag === 'string' || tag === 'date') return text_(tag); if (tag === 'integer' || tag === 'real') return Number(text_(tag).trim()); if (tag === 'data') return Buffer.from(text_(tag).replace(/\s+/g, ''), 'base64'); if (tag === 'array') { const out: PlistValue[] = []; for (;;) { skipWs(); if (toks[i]?.[1] === '/' && toks[i]![2] === 'array') { i++; return out; } out.push(value()); } } if (tag === 'dict') { const out: Record = {}; for (;;) { skipWs(); const k = toks[i]; if (k?.[1] === '/' && k[2] === 'dict') { i++; return out; } if (k?.[2] !== 'key') throw new Error('plist: expected in '); i++; out[text_('key')] = value(); } } throw new Error(`plist: unsupported <${tag}>`); } return value(); } /** Parses a binary plist ("bplist00"). */ export function parseBinaryPlist(b: Buffer): PlistValue { if (b.subarray(0, 8).toString('latin1') !== 'bplist00') throw new Error('not a binary plist'); const t = b.subarray(b.length - 32); const offSize = t[6]!, refSize = t[7]!; const numObjects = Number(t.readBigUInt64BE(8)), top = Number(t.readBigUInt64BE(16)), tableOff = Number(t.readBigUInt64BE(24)); const uint = (at: number, n: number): number => { let v = 0; for (let k = 0; k < n; k++) v = v * 256 + b[at + k]!; return v; }; const offset = (ref: number): number => uint(tableOff + ref * offSize, offSize); const depth = { n: 0 }; function obj(ref: number): PlistValue { if (ref >= numObjects) throw new Error('bplist: bad object reference'); if (++depth.n > 512) throw new Error('bplist: too deep'); try { const at = offset(ref); const m = b[at]!, hi = m >> 4, lo = m & 15; const len = (): [number, number] => { if (lo !== 15) return [lo, at + 1]; const sz = 1 << (b[at + 1]! & 15); return [uint(at + 2, sz), at + 2 + sz]; }; switch (hi) { case 0: return lo === 9; // 0x08 false, 0x09 true (0x00 null -> false) case 1: { const n = 1 << lo; return n === 8 ? Number(b.readBigInt64BE(at + 1)) : uint(at + 1, n); } case 2: return lo === 2 ? b.readFloatBE(at + 1) : b.readDoubleBE(at + 1); case 3: return new Date(Date.UTC(2001, 0, 1) + b.readDoubleBE(at + 1) * 1000).toISOString(); case 4: { const [n, s] = len(); return Buffer.from(b.subarray(s, s + n)); } case 5: { const [n, s] = len(); return b.subarray(s, s + n).toString('latin1'); } case 6: { const [n, s] = len(); const u = Buffer.from(b.subarray(s, s + n * 2)); u.swap16(); return u.toString('utf16le'); } case 8: return uint(at + 1, lo + 1); case 10: { const [n, s] = len(); return Array.from({ length: n }, (_, k) => obj(uint(s + k * refSize, refSize))); } case 13: { const [n, s] = len(); const out: Record = {}; for (let k = 0; k < n; k++) out[String(obj(uint(s + k * refSize, refSize)))] = obj(uint(s + (n + k) * refSize, refSize)); return out; } default: throw new Error(`bplist: unsupported object type 0x${m.toString(16)}`); } } finally { depth.n--; } } return obj(top); } /** XML or binary plist bytes, or XML plist text. */ export function parsePlist(b: Buffer | string): PlistValue { if (typeof b === 'string') return parseXmlPlist(b); return b.subarray(0, 8).toString('latin1') === 'bplist00' ? parseBinaryPlist(b) : parseXmlPlist(b.toString('utf8')); } /** The plist embedded in a .mobileprovision (a CMS envelope around an XML plist; the signature is not checked). */ export function parseMobileprovision(b: Buffer): Record { const s = b.toString('latin1'); const start = s.indexOf(''); if (start < 0 || end < 0) throw new Error('not a provisioning profile (no embedded plist)'); return parseXmlPlist(Buffer.from(s.slice(start, end + 8), 'latin1').toString('utf8')) as Record; }