chore: modernize APT runtime and CI
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const REQUIRED_EXTENSION_FILES = Object.freeze([
|
||||
'manifest.json', 'popup.html', 'popup.js', 'popup.css',
|
||||
'options.html', 'options.js', 'options.css',
|
||||
]);
|
||||
const TEXT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.md', '.ps1', '.yml', '.yaml']);
|
||||
const FORBIDDEN_PATTERNS = Object.freeze([
|
||||
['legacy bridge token', /apt-local-bridge-token/i],
|
||||
['legacy pairing header', /X-APT-Token/i],
|
||||
['legacy cookie proxy', /cookie-proxy-/i],
|
||||
['GitHub release API', /api\.github\.com/i],
|
||||
['executable updater', /download-and-install-update/i],
|
||||
['broad process termination', /taskkill/i],
|
||||
]);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function normalizeRelative(name) {
|
||||
if (typeof name !== 'string' || name.length === 0 || name.includes('\0')) fail(`Unsafe empty or NUL path: ${name}`);
|
||||
const unix = name.replaceAll('\\', '/');
|
||||
if (unix.startsWith('/') || /^[A-Za-z]:\//.test(unix)) fail(`Unsafe absolute path: ${name}`);
|
||||
const normalized = path.posix.normalize(unix);
|
||||
if (normalized === '..' || normalized.startsWith('../')) fail(`Unsafe parent path: ${name}`);
|
||||
return normalized.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function assertTextPolicy(name, content) {
|
||||
for (const [label, pattern] of FORBIDDEN_PATTERNS) {
|
||||
if (pattern.test(content)) fail(`Forbidden ${label} string in ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectory(root, { ignoreTopLevel = [] } = {}) {
|
||||
const absoluteRoot = path.resolve(root);
|
||||
if (!fs.statSync(absoluteRoot).isDirectory()) fail(`Expected a directory: ${root}`);
|
||||
const files = new Map();
|
||||
const ignored = new Set(ignoreTopLevel);
|
||||
const walk = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = normalizeRelative(path.relative(absoluteRoot, absolute));
|
||||
if (!relative.includes('/') && ignored.has(relative)) continue;
|
||||
if (entry.isSymbolicLink()) fail(`Unsafe symbolic link: ${relative}`);
|
||||
if (entry.isDirectory()) walk(absolute);
|
||||
else if (entry.isFile()) files.set(relative, () => fs.readFileSync(absolute));
|
||||
else fail(`Unsupported filesystem entry: ${relative}`);
|
||||
}
|
||||
};
|
||||
walk(absoluteRoot);
|
||||
return files;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(buffer) {
|
||||
const minimum = Math.max(0, buffer.length - 65_557);
|
||||
for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
|
||||
if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
|
||||
}
|
||||
fail('Invalid ZIP: end-of-central-directory record not found');
|
||||
}
|
||||
|
||||
function listZip(zipPath) {
|
||||
const zip = fs.readFileSync(zipPath);
|
||||
const end = findEndOfCentralDirectory(zip);
|
||||
const entryCount = zip.readUInt16LE(end + 10);
|
||||
let offset = zip.readUInt32LE(end + 16);
|
||||
const files = new Map();
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (zip.readUInt32LE(offset) !== 0x02014b50) fail('Invalid ZIP central-directory entry');
|
||||
const flags = zip.readUInt16LE(offset + 8);
|
||||
const method = zip.readUInt16LE(offset + 10);
|
||||
const compressedSize = zip.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = zip.readUInt32LE(offset + 24);
|
||||
const nameLength = zip.readUInt16LE(offset + 28);
|
||||
const extraLength = zip.readUInt16LE(offset + 30);
|
||||
const commentLength = zip.readUInt16LE(offset + 32);
|
||||
const externalAttributes = zip.readUInt32LE(offset + 38);
|
||||
const localOffset = zip.readUInt32LE(offset + 42);
|
||||
const name = normalizeRelative(zip.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
if (name.endsWith('/')) continue;
|
||||
const unixMode = externalAttributes >>> 16;
|
||||
if ((unixMode & 0o170000) === 0o120000) fail(`Unsafe ZIP symbolic link: ${name}`);
|
||||
if ((flags & 1) !== 0) fail(`Encrypted ZIP entries are unsupported: ${name}`);
|
||||
if (files.has(name)) fail(`Duplicate ZIP entry: ${name}`);
|
||||
|
||||
files.set(name, () => {
|
||||
if (zip.readUInt32LE(localOffset) !== 0x04034b50) fail(`Invalid ZIP local entry: ${name}`);
|
||||
const localNameLength = zip.readUInt16LE(localOffset + 26);
|
||||
const localExtraLength = zip.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + localNameLength + localExtraLength;
|
||||
const compressed = zip.subarray(start, start + compressedSize);
|
||||
const content = method === 0 ? compressed : method === 8 ? zlib.inflateRawSync(compressed) : fail(`Unsupported ZIP method ${method}: ${name}`);
|
||||
if (content.length !== uncompressedSize) fail(`Invalid ZIP size for ${name}`);
|
||||
return content;
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function listAsar(asarPath) {
|
||||
const asar = require('@electron/asar');
|
||||
const files = new Map();
|
||||
for (const archiveName of asar.listPackage(asarPath)) {
|
||||
if (!archiveName.startsWith('/')) fail(`Unsafe ASAR path: ${archiveName}`);
|
||||
const name = normalizeRelative(archiveName.slice(1));
|
||||
let stat;
|
||||
try {
|
||||
stat = asar.statFile(asarPath, name);
|
||||
} catch (error) {
|
||||
fail(`Invalid ASAR entry ${name}: ${error.message}`);
|
||||
}
|
||||
if (!Number.isSafeInteger(stat.size)) continue;
|
||||
if (!name.startsWith('node_modules/')) files.set(name, () => asar.extractFile(asarPath, name));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function findFile(files, suffix) {
|
||||
const matches = [...files.keys()].filter((name) => name === suffix || name.endsWith(`/${suffix}`));
|
||||
if (matches.length !== 1) fail(`Expected exactly one ${suffix}, found ${matches.length}`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function verifyExtension(files, expectedVersion) {
|
||||
const manifestName = findFile(files, 'chrome-extension/manifest.json');
|
||||
const extensionRoot = manifestName.slice(0, -'manifest.json'.length);
|
||||
for (const required of REQUIRED_EXTENSION_FILES) {
|
||||
if (!files.has(`${extensionRoot}${required}`)) fail(`Missing expected extension file: ${required}`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(files.get(manifestName)().toString('utf8'));
|
||||
} catch (error) {
|
||||
fail(`Invalid extension manifest: ${error.message}`);
|
||||
}
|
||||
if (manifest.version !== expectedVersion) {
|
||||
fail(`Extension version ${manifest.version} does not match application version ${expectedVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyTextFiles(files) {
|
||||
for (const [name, load] of files) {
|
||||
if (TEXT_EXTENSIONS.has(path.extname(name).toLowerCase())) assertTextPolicy(name, load().toString('utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
function verifySyntax(files) {
|
||||
const javascript = [...files.keys()].filter((name) => name.endsWith('.js'));
|
||||
javascript.push('scripts/verify-kit.js');
|
||||
for (const name of javascript) {
|
||||
const result = spawnSync(process.execPath, ['--check', path.join(ROOT, name)], { encoding: 'utf8' });
|
||||
if (result.status !== 0) fail(`JavaScript syntax check failed for ${name}: ${result.stderr.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySource() {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(ROOT, 'package-lock.json'), 'utf8'));
|
||||
const files = listDirectory(ROOT, { ignoreTopLevel: ['.git', 'dist', 'node_modules'] });
|
||||
verifyExtension(files, pkg.version);
|
||||
for (const [label, version] of [['lockfile', lock.version], ['lockfile root package', lock.packages?.['']?.version]]) {
|
||||
if (version !== pkg.version) fail(`${label} version ${version} does not match application version ${pkg.version}`);
|
||||
}
|
||||
const productionRoots = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/', 'chrome-extension/'];
|
||||
const productionFiles = new Map([...files].filter(([name]) =>
|
||||
productionRoots.some((root) => name === root || name.startsWith(root))
|
||||
));
|
||||
verifyTextFiles(productionFiles);
|
||||
verifySyntax(productionFiles);
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function verifySupplied(target, expectedVersion, mode) {
|
||||
const absolute = path.resolve(target);
|
||||
if (!fs.existsSync(absolute)) fail(`Supplied ${mode} does not exist: ${target}`);
|
||||
const isDirectory = fs.statSync(absolute).isDirectory();
|
||||
const files = isDirectory ? listDirectory(absolute) : listZip(absolute);
|
||||
const hostNames = [...files.keys()];
|
||||
if (mode === 'build' && !hostNames.includes('chrome-extension/manifest.json')) {
|
||||
const asarName = hostNames.find((name) => /(?:^|\/)resources\/app\.asar$/i.test(name));
|
||||
if (!asarName || !isDirectory) fail('Missing packaged app.asar in supplied build');
|
||||
for (const [name, load] of listAsar(path.join(absolute, asarName))) files.set(name, load);
|
||||
}
|
||||
verifyExtension(files, expectedVersion);
|
||||
verifyTextFiles(files);
|
||||
if (!hostNames.some((name) => /(?:^|\/)Alta(?:CameraProxy| Camera Proxy)(?:-[^/]*)?\.exe$/i.test(name))) {
|
||||
fail(`Missing expected APT executable in supplied ${mode}`);
|
||||
}
|
||||
if (mode === 'kit' && !hostNames.some((name) => /(?:^|\/)aware-cam-proxy\.exe$/i.test(name))) {
|
||||
fail('Missing expected aware-cam-proxy.exe in supplied kit');
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const version = verifySource();
|
||||
if (argv.length > 0) {
|
||||
const buildMode = argv[0] === '--build';
|
||||
const target = buildMode ? argv[1] : argv[0];
|
||||
if (!target || argv.length !== (buildMode ? 2 : 1)) fail('Usage: node scripts/verify-kit.js [--build <directory>|<kit-directory-or-zip>]');
|
||||
verifySupplied(target, version, buildMode ? 'build' : 'kit');
|
||||
}
|
||||
process.stdout.write(`APT source${argv.length ? ' and supplied artifact' : ''} verified at version ${version}.\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`Verification failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizeRelative, verifySource, verifySupplied };
|
||||
Reference in New Issue
Block a user