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

150 lines
6.0 KiB
JavaScript

import zlib from 'node:zlib';
import { basenameAny, normalizePackagePath } from '../utils/pathUtils.js';
// Assembles a PSADT v4 package tree from a rendered profile script plus its linked
// Asset Library files, and zips it without an archiver dependency. The layout
// planning and the ZIP writer are pure and unit-tested; disk reads stay in the
// controller.
export const ENTRY_SCRIPT_NAME = 'Invoke-AppDeployToolkit.ps1';
// Link role -> default PSADT v4 subfolder when the link carries no explicit
// package path. Installers/payload go under Files, supporting content under
// SupportFiles, icons under Assets.
const ROLE_FOLDER = {
installer: 'Files',
'package-file': 'Files',
'support-file': 'SupportFiles',
'detection-script': 'SupportFiles',
icon: 'Assets',
reference: 'SupportFiles'
};
// Pure: decide where the rendered script and each linked asset land in the
// package tree. An explicit link packagePath wins; otherwise the role picks a
// standard folder and the asset's own file name. Collisions are dropped with a
// warning so a package never silently overwrites a file.
export function planPackageLayout(links = []) {
const warnings = [];
const used = new Map();
used.set(ENTRY_SCRIPT_NAME.toLowerCase(), ENTRY_SCRIPT_NAME);
const assets = [];
for (const link of links) {
const folder = ROLE_FOLDER[link.linkRole] || 'SupportFiles';
const name = basenameAny(link.packagePath || link.originalName || link.assetId);
let target = normalizePackagePath(link.packagePath, `${folder}/${name}`) || `${folder}/${name}`;
if (!target.includes('/')) target = `${folder}/${target}`;
const key = target.toLowerCase();
if (used.has(key)) {
warnings.push(`Skipped "${link.originalName || link.assetId}": another file already maps to ${target}.`);
continue;
}
used.set(key, link.originalName || link.assetId);
assets.push({ assetId: link.assetId, packagePath: target, role: link.linkRole, name: link.originalName });
}
const directories = ['Files', 'SupportFiles'];
if (assets.some((asset) => asset.packagePath.startsWith('Assets/'))) directories.push('Assets');
return { scriptPath: ENTRY_SCRIPT_NAME, assets, directories, warnings };
}
// Pure: pick the installer the .intunewin build should treat as its entry setup
// file (relative to the package root). An explicit choice wins; otherwise prefer
// an installer-role asset, then the first file under Files/.
export function resolveSetupFile(layout, explicit = '') {
const explicitClean = normalizePackagePath(explicit, '');
if (explicitClean) return explicitClean;
const installer = layout.assets.find((asset) => asset.role === 'installer');
if (installer) return installer.packagePath;
const firstFile = layout.assets.find((asset) => asset.packagePath.startsWith('Files/'));
return firstFile ? firstFile.packagePath : '';
}
let CRC_TABLE;
function crc32(buf) {
if (!CRC_TABLE) {
CRC_TABLE = new Int32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
CRC_TABLE[n] = c;
}
}
let crc = -1;
for (let i = 0; i < buf.length; i += 1) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
return (crc ^ -1) >>> 0;
}
// Pure: build a standard PKZIP buffer from entries. Files are deflate-compressed
// (method 8), directories and empty files stored (method 0). A fixed 1980 DOS
// timestamp keeps output reproducible. Round-trips through zlib.inflateRawSync.
export function buildZip(entries = []) {
const local = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const isDir = Boolean(entry.isDirectory);
let name = String(entry.path).replace(/\\/g, '/');
if (isDir && !name.endsWith('/')) name += '/';
const nameBytes = Buffer.from(name, 'utf8');
const raw = isDir ? Buffer.alloc(0) : (Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '', 'utf8'));
const deflated = raw.length ? zlib.deflateRawSync(raw) : Buffer.alloc(0);
const useDeflate = raw.length > 0 && deflated.length < raw.length;
const method = useDeflate ? 8 : 0;
const stored = useDeflate ? deflated : raw;
const crc = crc32(raw);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0, 6);
localHeader.writeUInt16LE(method, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0x21, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(stored.length, 18);
localHeader.writeUInt32LE(raw.length, 22);
localHeader.writeUInt16LE(nameBytes.length, 26);
localHeader.writeUInt16LE(0, 28);
local.push(localHeader, nameBytes, stored);
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0, 8);
centralHeader.writeUInt16LE(method, 10);
centralHeader.writeUInt16LE(0, 12);
centralHeader.writeUInt16LE(0x21, 14);
centralHeader.writeUInt32LE(crc, 16);
centralHeader.writeUInt32LE(stored.length, 20);
centralHeader.writeUInt32LE(raw.length, 24);
centralHeader.writeUInt16LE(nameBytes.length, 28);
centralHeader.writeUInt16LE(0, 30);
centralHeader.writeUInt16LE(0, 32);
centralHeader.writeUInt16LE(0, 34);
centralHeader.writeUInt16LE(0, 36);
centralHeader.writeUInt32LE(isDir ? 0x10 : 0, 38);
centralHeader.writeUInt32LE(offset, 42);
central.push(centralHeader, nameBytes);
offset += localHeader.length + nameBytes.length + stored.length;
}
const centralBuffer = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralBuffer.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...local, centralBuffer, end]);
}