Files
poshmanager/server/services/msiReader.js
2026-08-09 17:33:11 -05:00

166 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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