512 lines
22 KiB
JavaScript
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();
|
|
}
|