166 lines
6.6 KiB
JavaScript
166 lines
6.6 KiB
JavaScript
// Read identity metadata from a Windows Installer (.msi) database. An MSI is a
|
||
// compound file (see cfbReader) whose tables are stored as streams. We pull the
|
||
// `Property` table (ProductCode/Version/Name, UpgradeCode, Manufacturer) and the
|
||
// SummaryInformation property set (subject/author/template) — enough to auto-fill
|
||
// a deployment and a product-code detection rule with zero typing. Dependency-
|
||
// free and read-only; the table-decoding helpers are pure and unit-tested.
|
||
|
||
import { isCompoundFile, readCompoundFile } from './cfbReader.js';
|
||
|
||
const SUMMARY_STREAM = 'SummaryInformation';
|
||
|
||
// MSI mangles table/stream names into a Unicode private range. Encode a plain
|
||
// table name ("Property") the same way the directory stores it, so we can look
|
||
// it up in the decoded streams map. [A-Za-z0-9._] map to 6-bit codes packed in
|
||
// pairs (0x3800 base) with a trailing odd char at 0x4800.
|
||
function encChar(code) {
|
||
if (code >= 0x30 && code <= 0x39) return code - 0x30; // 0-9
|
||
if (code >= 0x41 && code <= 0x5a) return code - 0x41 + 10; // A-Z
|
||
if (code >= 0x61 && code <= 0x7a) return code - 0x61 + 36; // a-z
|
||
if (code === 0x2e) return 62; // .
|
||
if (code === 0x5f) return 63; // _
|
||
return -1;
|
||
}
|
||
|
||
export function encodeStreamName(name) {
|
||
let out = '';
|
||
let i = 0;
|
||
while (i < name.length) {
|
||
const c1 = encChar(name.charCodeAt(i));
|
||
if (c1 < 0) { out += name[i]; i += 1; continue; }
|
||
const c2 = i + 1 < name.length ? encChar(name.charCodeAt(i + 1)) : -1;
|
||
if (c2 >= 0) { out += String.fromCharCode(0x3800 + c1 + c2 * 0x40); i += 2; }
|
||
else { out += String.fromCharCode(0x4800 + c1); i += 1; }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function decoderFor(codepage) {
|
||
if (codepage === 65001) return (b) => b.toString('utf8');
|
||
if (codepage === 1200) return (b) => b.toString('utf16le');
|
||
return (b) => b.toString('latin1'); // 1252/0/ASCII — fine for property keys + GUIDs
|
||
}
|
||
|
||
// _StringPool is an array of (size, refcount) uint16 pairs; slot 0 carries the
|
||
// codepage. _StringData is the concatenated bytes. Strings are 1-indexed; a zero
|
||
// size with a nonzero refcount marks a >64KB string whose length spans two slots.
|
||
export function decodeStringPool(poolBuf, dataBuf) {
|
||
const codepage = poolBuf.length >= 2 ? poolBuf.readUInt16LE(0) : 0;
|
||
const decode = decoderFor(codepage);
|
||
const strings = ['']; // index 0 is always the empty string
|
||
let dataOff = 0;
|
||
let i = 1;
|
||
while (i * 4 + 4 <= poolBuf.length) {
|
||
let len = poolBuf.readUInt16LE(i * 4);
|
||
const refcount = poolBuf.readUInt16LE(i * 4 + 2);
|
||
i += 1;
|
||
if (len === 0 && refcount === 0) { strings.push(''); continue; }
|
||
if (len === 0 && i * 4 + 4 <= poolBuf.length) {
|
||
len = (refcount << 16) | poolBuf.readUInt16LE(i * 4);
|
||
i += 1;
|
||
}
|
||
strings.push(decode(dataBuf.subarray(dataOff, dataOff + len)));
|
||
dataOff += len;
|
||
}
|
||
return { codepage, strings };
|
||
}
|
||
|
||
// The Property table has two string columns (Property, Value), stored column-
|
||
// major. Cells are string-pool indices: 2 bytes, or 3 when the pool is huge.
|
||
export function parsePropertyTable(tableBuf, strings) {
|
||
const idxWidth = strings.length > 0xffff ? 3 : 2;
|
||
const rowBytes = idxWidth * 2;
|
||
const rows = Math.floor(tableBuf.length / rowBytes);
|
||
const readIdx = (off) => (idxWidth === 2 ? tableBuf.readUInt16LE(off) : tableBuf.readUIntLE(off, 3));
|
||
const props = {};
|
||
for (let r = 0; r < rows; r += 1) {
|
||
const key = strings[readIdx(r * idxWidth)];
|
||
if (!key) continue;
|
||
props[key] = strings[readIdx(rows * idxWidth + r * idxWidth)] || '';
|
||
}
|
||
return props;
|
||
}
|
||
|
||
function readPropValue(buf, off, type, decode) {
|
||
switch (type) {
|
||
case 2: return buf.readInt16LE(off); // VT_I2
|
||
case 3: return buf.readInt32LE(off); // VT_I4
|
||
case 30: { // VT_LPSTR
|
||
const len = buf.readUInt32LE(off);
|
||
return decode(buf.subarray(off + 4, off + 4 + len)).replace(/\0+$/, '');
|
||
}
|
||
case 31: { // VT_LPWSTR
|
||
const len = buf.readUInt32LE(off);
|
||
return buf.toString('utf16le', off + 4, off + 4 + len * 2).replace(/\0+$/, '');
|
||
}
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
// SummaryInformation is a standard OLE property set ([MS-OLEPS]). We read the
|
||
// first section and pull the PIDs MSI populates (subject, author, template…).
|
||
export function parseSummaryInformation(buf) {
|
||
if (!buf || buf.length < 48) return {};
|
||
const sectionOffset = buf.readUInt32LE(44); // FMTID(16) at 28, its offset at 44
|
||
if (sectionOffset + 8 > buf.length) return {};
|
||
const count = buf.readUInt32LE(sectionOffset + 4);
|
||
const idOffsets = [];
|
||
for (let i = 0; i < count && sectionOffset + 8 + i * 8 + 8 <= buf.length; i += 1) {
|
||
idOffsets.push([
|
||
buf.readUInt32LE(sectionOffset + 8 + i * 8),
|
||
sectionOffset + buf.readUInt32LE(sectionOffset + 8 + i * 8 + 4)
|
||
]);
|
||
}
|
||
let codepage = 0;
|
||
for (const [pid, off] of idOffsets) {
|
||
if (pid === 1 && off + 6 <= buf.length && buf.readUInt32LE(off) === 2) {
|
||
const cp = buf.readInt16LE(off + 4);
|
||
codepage = cp < 0 ? cp + 65536 : cp;
|
||
}
|
||
}
|
||
const decode = decoderFor(codepage);
|
||
const PID = { 2: 'title', 3: 'subject', 4: 'author', 6: 'comments', 7: 'template', 9: 'revNumber', 18: 'creatingApp' };
|
||
const out = {};
|
||
for (const [pid, off] of idOffsets) {
|
||
if (!PID[pid] || off + 4 > buf.length) continue;
|
||
const value = readPropValue(buf, off + 4, buf.readUInt32LE(off), decode);
|
||
if (value != null) out[PID[pid]] = value;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Pure: turn a decoded streams map (from cfbReader) into normalized metadata.
|
||
export function extractMsiMetadata(streams) {
|
||
const props = {};
|
||
const pool = streams.get('_StringPool');
|
||
const data = streams.get('_StringData');
|
||
if (pool && data) {
|
||
const { strings } = decodeStringPool(pool, data);
|
||
const table = streams.get(encodeStreamName('Property'));
|
||
if (table) Object.assign(props, parsePropertyTable(table, strings));
|
||
}
|
||
const summary = parseSummaryInformation(streams.get(SUMMARY_STREAM));
|
||
const [platform = '', language = ''] = String(summary.template || '').split(';');
|
||
return {
|
||
source: 'msi',
|
||
productName: props.ProductName || summary.subject || summary.title || '',
|
||
productVersion: props.ProductVersion || '',
|
||
productCode: props.ProductCode || '',
|
||
upgradeCode: props.UpgradeCode || '',
|
||
manufacturer: props.Manufacturer || summary.author || '',
|
||
language: props.ProductLanguage || language.trim(),
|
||
platform: platform.trim(),
|
||
packageCode: summary.revNumber || '',
|
||
properties: props
|
||
};
|
||
}
|
||
|
||
export function isMsi(buffer) {
|
||
return isCompoundFile(buffer);
|
||
}
|
||
|
||
export function readMsiMetadata(buffer) {
|
||
if (!isCompoundFile(buffer)) throw new Error('Not an MSI (missing OLE2 compound-file signature)');
|
||
return extractMsiMetadata(readCompoundFile(buffer).streams);
|
||
}
|