aps-agent/scripts/offline-install-check.mjs

512 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* scripts/offline-install-check.mjs -- offline installer integrity check for APS desktop.
*
* Generates and verifies a SHA-256 checksum manifest for the NSIS installer and related
* release artifacts, and runs a dry-run install-path check. The expected install
* directory is derived read-only from apps/desktop/package.json (electron-builder NSIS
* per-user default): %LOCALAPPDATA%\Programs\<productName>.
*
* Usage:
* node scripts/offline-install-check.mjs generate + verify + dry-run
* node scripts/offline-install-check.mjs --verify verify an existing manifest
* node scripts/offline-install-check.mjs --portable copy installer next to manifest and
* emit paths relative to the package dir
* node scripts/offline-install-check.mjs --out-dir <dir> manifest output dir (default build/offline-check)
* node scripts/offline-install-check.mjs --root <dir> base dir used to resolve manifest paths
* node scripts/offline-install-check.mjs --localappdata <p> override %LOCALAPPDATA% for the dry-run
* node scripts/offline-install-check.mjs --json machine-readable JSON report
* node scripts/offline-install-check.mjs --self-test internal round-trip smoke (temp files only)
*
* Exit codes:
* 0 PASS -- installer present, size ok, every manifest checksum verifies
* 1 FAIL -- installer missing/too small, checksum mismatch, or unreadable artifact
*
* Offline transfer flow:
* 1. On the build machine: node scripts/offline-install-check.mjs --portable
* -> build/offline-check/ now contains installer + checksums.sha256 +
* offline-install-manifest.json (paths relative to that folder).
* 2. Copy that folder to USB media, then on the target offline machine run:
* node scripts/offline-install-check.mjs --verify --root <media-folder>
* 3. Install and confirm the app lands in %LOCALAPPDATA%\Programs\<productName>
* (the dry-run output prints the expected path on the current machine).
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_OUT_DIR = path.join(repoRoot, 'build', 'offline-check');
const INSTALLER_MIN_BYTES = 50 * 1024 * 1024;
const SIDECAR_MIN_BYTES = 5 * 1024 * 1024;
const MANIFEST_FILE = 'offline-install-manifest.json';
const CHECKSUMS_FILE = 'checksums.sha256';
const EXIT_PASS = 0;
const EXIT_FAIL = 1;
export function toPosix(value) {
return String(value).replaceAll('\\', '/');
}
export function readDesktopConfig(root) {
const file = path.join(root, 'apps', 'desktop', 'package.json');
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const build = raw.build || {};
return {
file,
version: raw.version || '0.0.0',
productName: build.productName || raw.name || 'aps-agent',
appId: build.appId || 'unknown',
artifactName: (build.win && build.win.artifactName) || 'aps-agent-desktop-' + '$' + '{version}' + '.' + '$' + '{ext}',
outputDir: (build.directories && build.directories.output) || 'release',
};
}
export function resolveInstallerName(config) {
const versionToken = '$' + '{version}';
const extToken = '$' + '{ext}';
return String(config.artifactName || '')
.replace(versionToken, config.version)
.replace(extToken, 'exe');
}
export function resolveArtifacts(root, config) {
const desktopDir = path.join(root, 'apps', 'desktop', config.outputDir);
const entries = [
{ id: 'nsis-installer', kind: 'installer', file: path.join(desktopDir, resolveInstallerName(config)), minBytes: INSTALLER_MIN_BYTES },
{ id: 'sidecar-exe', kind: 'sidecar', file: path.join(root, 'dist', 'sidecar', 'aps-sidecar', 'aps-sidecar.exe'), minBytes: SIDECAR_MIN_BYTES },
{ id: 'desktop-frozen-exe', kind: 'desktop', file: path.join(desktopDir, 'win-unpacked', config.productName + '.exe'), minBytes: SIDECAR_MIN_BYTES },
{ id: 'bundled-sidecar-exe', kind: 'sidecar', file: path.join(desktopDir, 'win-unpacked', 'resources', 'sidecar', 'aps-sidecar.exe'), minBytes: SIDECAR_MIN_BYTES },
];
return entries.map((entry) => {
const stat = fs.existsSync(entry.file) ? fs.statSync(entry.file) : null;
return Object.assign({}, entry, { exists: Boolean(stat), bytes: stat ? stat.size : 0, sizeOk: Boolean(stat && stat.size >= entry.minBytes) });
});
}
export async function sha256File(file) {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(file);
for await (const chunk of stream) hash.update(chunk);
return hash.digest('hex');
}
export function checksumsContent(entries, baseDir) {
return entries
.map((entry) => entry.sha256 + ' ' + toPosix(path.relative(baseDir, entry.file)))
.join('\n') + '\n';
}
export function parseChecksumsFile(content) {
const entries = [];
const lines = String(content || '').split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = /^([0-9a-fA-F]{64})\s+\*?\s*(.+)$/.exec(trimmed);
if (!match) throw new Error('Malformed checksums line: ' + line);
entries.push({ sha256: match[1].toLowerCase(), file: match[2].trim() });
}
return entries;
}
export function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return '?';
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return (unit === 0 ? String(value) : value.toFixed(1)) + ' ' + units[unit];
}
export function expectedInstallDir(localAppData, productName) {
if (!localAppData) return null;
return path.join(localAppData, 'Programs', productName);
}
export function dryRunInstallPath(localAppData, productName) {
const dir = expectedInstallDir(localAppData, productName);
if (!dir) {
return { expectedInstallDir: null, exists: false, structure: null, layoutValid: false, reason: 'LOCALAPPDATA is not set' };
}
const result = { expectedInstallDir: dir, exists: fs.existsSync(dir), structure: null, layoutValid: null, reason: null };
if (!result.exists) {
result.reason = 'expected install dir absent (dry-run only; actual install needs a clean offline machine)';
return result;
}
const appExe = fs.existsSync(path.join(dir, productName + '.exe'));
const sidecarExe = fs.existsSync(path.join(dir, 'resources', 'sidecar', 'aps-sidecar.exe'));
result.structure = { appExe, sidecarExe };
result.layoutValid = Boolean(appExe && sidecarExe);
result.reason = result.layoutValid ? 'existing install layout detected' : 'install dir exists but layout is incomplete/stale';
return result;
}
export function checkInstaller(file, minBytes) {
const stat = fs.existsSync(file) ? fs.statSync(file) : null;
return { exists: Boolean(stat), bytes: stat ? stat.size : 0, sizeOk: Boolean(stat && stat.size >= minBytes), minBytes };
}
export function buildManifest(entries, config, baseDir) {
return {
schemaVersion: 1,
generatedBy: 'scripts/offline-install-check.mjs',
generatedAt: new Date().toISOString(),
productName: config.productName,
version: config.version,
appId: config.appId,
baseDir,
expectedInstallDir: expectedInstallDir(process.env.LOCALAPPDATA, config.productName),
notes: 'Local integrity manifest for the offline-install smoke; not a signed attestation. Ship the installer together with checksums.sha256 to the target machine.',
entries: entries.map((entry) => ({
id: entry.id,
kind: entry.kind,
file: toPosix(path.relative(baseDir, entry.file)),
sha256: entry.sha256,
bytes: entry.bytes,
minBytes: entry.minBytes,
})),
};
}
export async function verifyEntries(entries, root) {
const results = [];
for (const entry of entries) {
const file = path.resolve(root, entry.file);
const exists = fs.existsSync(file);
let actual = null;
let bytes = null;
let error = null;
if (exists) {
try {
actual = await sha256File(file);
bytes = fs.statSync(file).size;
} catch (caught) {
error = String((caught && caught.message) || caught);
}
}
if (!entry.sha256 && !error) error = 'manifest entry missing sha256';
const sizeOk = exists && !error && bytes !== null && bytes >= (entry.minBytes || 1);
results.push({
id: entry.id,
kind: entry.kind,
file: entry.file,
exists,
actual,
bytes,
expected: entry.sha256,
expectedBytes: entry.bytes,
match: Boolean(exists && !error && entry.sha256 && actual === entry.sha256),
sizeOk,
error,
});
}
return results;
}
export function printReport(report) {
const lines = [];
lines.push('Offline installer integrity check (' + report.mode + ')');
if (report.reason) lines.push(' reason: ' + report.reason);
if (report.productName) lines.push(' product: ' + report.productName + (report.version ? ' ' + report.version : ''));
if (report.manifest) lines.push(' manifest: ' + report.manifest);
if (report.checksums) lines.push(' checksums: ' + report.checksums);
for (const entry of report.entries || []) {
const tag = entry.exists && entry.match && entry.sizeOk ? 'PASS' : 'FAIL';
let detail = 'sha256 ' + (entry.actual || 'n/a');
if (entry.bytes != null) detail += ' / ' + formatBytes(entry.bytes);
if (entry.expectedBytes != null) detail += ' (expected ' + formatBytes(entry.expectedBytes) + ')';
if (entry.error) detail += ' error: ' + entry.error;
lines.push(' [' + tag + '] ' + (entry.id || entry.file) + ' -- ' + entry.file + ' ' + detail);
}
for (const missing of report.missing || []) {
lines.push(' [MISSING] ' + missing.id + ' -- ' + missing.file + ' (not packaged; skipped)');
}
if (report.dryRun) {
lines.push(' dry-run install path: ' + (report.dryRun.expectedInstallDir || 'n/a'));
lines.push(' dry-run status: ' + (report.dryRun.reason || 'n/a'));
}
lines.push('Verdict: ' + report.verdict);
console.log(lines.join('\n'));
}
export function printHelp() {
console.log([
'Usage: node scripts/offline-install-check.mjs [options]',
'',
'Generate/verify a SHA-256 checksum manifest for the NSIS installer and run a',
'dry-run install-path check.',
'',
'Options:',
' --verify verify an existing manifest (default: generate then verify)',
' --portable copy the installer next to the manifest and emit paths',
' relative to the package dir (offline transfer bundle)',
' --out-dir <dir> manifest output dir (default build/offline-check)',
' --root <dir> base dir used to resolve manifest paths (default repo root)',
' --localappdata <path> override %LOCALAPPDATA% for the dry-run',
' --manifest <path> manifest to verify (default <out-dir>/offline-install-manifest.json)',
' --json machine-readable JSON report',
' --self-test internal round-trip smoke (temp files only)',
' -h, --help this help',
'',
'Exit codes: 0 PASS / 1 FAIL',
].join('\n'));
}
export function parseArgs(argv) {
const args = {
verify: false,
portable: false,
json: false,
selfTest: false,
help: false,
outDir: DEFAULT_OUT_DIR,
root: repoRoot,
localAppData: process.env.LOCALAPPDATA || null,
manifest: null,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--verify') args.verify = true;
else if (arg === '--portable') args.portable = true;
else if (arg === '--json') args.json = true;
else if (arg === '--self-test') args.selfTest = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else if (arg === '--out-dir') {
i += 1;
args.outDir = path.resolve(argv[i]);
} else if (arg === '--root') {
i += 1;
args.root = path.resolve(argv[i]);
} else if (arg === '--localappdata') {
i += 1;
args.localAppData = argv[i];
} else if (arg === '--manifest') {
i += 1;
args.manifest = path.resolve(argv[i]);
} else if (arg.startsWith('--')) {
throw new Error('Unknown option: ' + arg);
} else {
throw new Error('Unexpected positional argument: ' + arg);
}
}
return args;
}
async function runGenerate(args) {
const config = readDesktopConfig(repoRoot);
const artifacts = resolveArtifacts(repoRoot, config);
const installer = artifacts.find((artifact) => artifact.id === 'nsis-installer');
const missing = artifacts.filter((artifact) => !artifact.exists);
const tooSmall = artifacts.filter((artifact) => artifact.exists && !artifact.sizeOk);
if (!installer.exists) {
return { verdict: 'FAIL', mode: 'generate', reason: 'NSIS installer missing: ' + installer.file, missing: missing.map((artifact) => artifact.id), productName: config.productName, version: config.version };
}
if (!installer.sizeOk) {
return { verdict: 'FAIL', mode: 'generate', reason: 'NSIS installer too small: ' + formatBytes(installer.bytes) + ' (< ' + formatBytes(installer.minBytes) + ')', installer: { file: installer.file, bytes: installer.bytes }, productName: config.productName, version: config.version };
}
if (tooSmall.length > 0) {
return { verdict: 'FAIL', mode: 'generate', reason: 'artifact too small: ' + tooSmall.map((artifact) => artifact.id + ' ' + formatBytes(artifact.bytes)).join(', '), productName: config.productName, version: config.version };
}
fs.mkdirSync(args.outDir, { recursive: true });
let included;
let baseDir;
if (args.portable) {
const portableInstaller = path.join(args.outDir, path.basename(installer.file));
fs.copyFileSync(installer.file, portableInstaller);
const stat = fs.statSync(portableInstaller);
included = [Object.assign({}, installer, { file: portableInstaller, exists: true, bytes: stat.size, sizeOk: stat.size >= installer.minBytes })];
included[0].sha256 = await sha256File(portableInstaller);
baseDir = args.outDir;
} else {
included = [];
for (const artifact of artifacts) {
if (!artifact.exists) continue;
artifact.sha256 = await sha256File(artifact.file);
included.push(artifact);
}
baseDir = args.root;
}
const manifest = buildManifest(included, config, baseDir);
const manifestPath = path.join(args.outDir, MANIFEST_FILE);
const checksumsPath = path.join(args.outDir, CHECKSUMS_FILE);
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
fs.writeFileSync(checksumsPath, checksumsContent(included, baseDir), 'utf8');
const results = await verifyEntries(manifest.entries, baseDir);
const dryRun = dryRunInstallPath(args.localAppData, config.productName);
const failed = results.filter((entry) => !(entry.exists && entry.match && entry.sizeOk));
return {
verdict: failed.length === 0 ? 'PASS' : 'FAIL',
mode: 'generate',
portable: args.portable,
productName: config.productName,
version: config.version,
manifest: manifestPath,
checksums: checksumsPath,
baseDir,
expectedInstallDir: dryRun.expectedInstallDir,
dryRun,
missing: missing.map((artifact) => ({ id: artifact.id, file: toPosix(path.relative(repoRoot, artifact.file)) })),
entries: results,
failedCount: failed.length,
};
}
async function runVerify(args) {
const manifestPath = args.manifest || path.join(args.outDir, MANIFEST_FILE);
if (!fs.existsSync(manifestPath)) {
return { verdict: 'FAIL', mode: 'verify', reason: 'manifest not found: ' + manifestPath, manifest: manifestPath };
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
return { verdict: 'FAIL', mode: 'verify', reason: 'manifest unreadable: ' + String((error && error.message) || error), manifest: manifestPath };
}
if (manifest.schemaVersion !== 1) {
return { verdict: 'FAIL', mode: 'verify', reason: 'unsupported manifest schemaVersion: ' + manifest.schemaVersion, manifest: manifestPath };
}
const results = await verifyEntries(manifest.entries || [], args.root);
const config = readDesktopConfig(repoRoot);
const productName = manifest.productName || config.productName;
const dryRun = dryRunInstallPath(args.localAppData, productName);
const failed = results.filter((entry) => !(entry.exists && entry.match && entry.sizeOk));
return {
verdict: failed.length === 0 ? 'PASS' : 'FAIL',
mode: 'verify',
productName,
version: manifest.version,
manifest: manifestPath,
root: args.root,
expectedInstallDir: dryRun.expectedInstallDir,
dryRun,
entries: results,
failedCount: failed.length,
};
}
export async function runSelfTest() {
const checks = [];
const check = async (name, fn) => {
try {
await fn();
checks.push({ name, pass: true });
} catch (error) {
checks.push({ name, pass: false, error: String((error && error.message) || error) });
}
};
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-offline-self-'));
try {
const installer = path.join(root, 'installer.exe');
const sidecar = path.join(root, 'sidecar.exe');
fs.writeFileSync(installer, Buffer.alloc(256 * 1024, 7));
fs.writeFileSync(sidecar, Buffer.alloc(4096, 9));
await check('sha256File is deterministic lowercase hex', async () => {
const first = await sha256File(installer);
const second = await sha256File(installer);
if (first !== second || !/^[0-9a-f]{64}$/.test(first)) throw new Error('bad hash');
});
await check('checksumsContent + parseChecksumsFile round trip', async () => {
const installerHash = await sha256File(installer);
const content = checksumsContent([{ file: installer, sha256: installerHash }], root);
const parsed = parseChecksumsFile(content);
if (parsed.length !== 1 || parsed[0].sha256 !== installerHash || parsed[0].file !== 'installer.exe') {
throw new Error('round trip mismatch');
}
});
await check('expectedInstallDir under LOCALAPPDATA Programs', () => {
const dir = expectedInstallDir('C:\\Users\\tester\\AppData\\Local', '工业智核 APS');
if (dir !== 'C:\\Users\\tester\\AppData\\Local\\Programs\\工业智核 APS') throw new Error('got ' + dir);
});
await check('dryRunInstallPath absent dir', () => {
const result = dryRunInstallPath(path.join(root, 'no-such-local'), '工业智核 APS');
if (result.exists || result.layoutValid !== null) throw new Error('expected absent dir');
if (result.expectedInstallDir.indexOf('Programs' + path.sep + '工业智核 APS') === -1) throw new Error('bad expected dir');
});
await check('dryRunInstallPath detects full layout', () => {
const local = path.join(root, 'LocalAppData');
const installDir = path.join(local, 'Programs', '工业智核 APS');
fs.mkdirSync(path.join(installDir, 'resources', 'sidecar'), { recursive: true });
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'x');
fs.writeFileSync(path.join(installDir, 'resources', 'sidecar', 'aps-sidecar.exe'), 'y');
const result = dryRunInstallPath(local, '工业智核 APS');
if (!result.exists || !result.layoutValid) throw new Error('expected valid layout, got ' + JSON.stringify(result));
});
await check('checkInstaller size gate', () => {
const ok = checkInstaller(installer, 1024);
if (!ok.sizeOk) throw new Error('expected sizeOk');
const missing = checkInstaller(path.join(root, 'nope.exe'), 1024);
if (missing.exists) throw new Error('expected missing');
const small = checkInstaller(sidecar, 10 * 1024 * 1024);
if (small.sizeOk) throw new Error('expected too small');
});
await check('manifest verify detects tampering', async () => {
const config = { productName: '工业智核 APS', version: '0.1.0', appId: 'com.aps.agent' };
const installerHash = await sha256File(installer);
const sidecarHash = await sha256File(sidecar);
const entries = [
{ id: 'nsis-installer', kind: 'installer', file: installer, sha256: installerHash, bytes: fs.statSync(installer).size, minBytes: 1024 },
{ id: 'sidecar-exe', kind: 'sidecar', file: sidecar, sha256: sidecarHash, bytes: fs.statSync(sidecar).size, minBytes: 1024 },
];
const manifest = buildManifest(entries, config, root);
const first = await verifyEntries(manifest.entries, root);
if (!first.every((entry) => entry.match && entry.sizeOk)) throw new Error('clean verify should pass');
fs.appendFileSync(sidecar, 'tampered');
const second = await verifyEntries(manifest.entries, root);
const tampered = second.find((entry) => entry.id === 'sidecar-exe');
if (tampered.match || !tampered.exists) throw new Error('tampered entry should mismatch');
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
let failed = 0;
for (const entry of checks) {
if (entry.pass) {
console.log(' [PASS] ' + entry.name);
} else {
failed += 1;
console.log(' [FAIL] ' + entry.name + ': ' + entry.error);
}
}
console.log('offline-install-check self-test: ' + (failed === 0 ? 'PASS (' + checks.length + '/' + checks.length + ')' : 'FAIL (' + (checks.length - failed) + '/' + checks.length + ')'));
process.exitCode = failed === 0 ? EXIT_PASS : EXIT_FAIL;
}
async function main() {
let args;
try {
args = parseArgs(process.argv.slice(2));
} catch (error) {
console.error('offline-install-check: ' + String((error && error.message) || error));
process.exitCode = EXIT_FAIL;
return;
}
if (args.help) {
printHelp();
return;
}
if (args.selfTest) {
await runSelfTest();
return;
}
const report = args.verify ? await runVerify(args) : await runGenerate(args);
if (args.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printReport(report);
}
process.exitCode = report.verdict === 'PASS' ? EXIT_PASS : EXIT_FAIL;
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) {
await main();
}