mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(release): add verified Linux packages
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
fail(`Failed to run ${command}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
if (options.stdio === 'pipe') {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
}
|
||||
fail(`${command} exited with status ${result.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findSingle(directory, extension, label) {
|
||||
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
||||
fail(`${label} directory does not exist: ${directory}`);
|
||||
}
|
||||
|
||||
const matches = fs.readdirSync(directory)
|
||||
.filter(name => name.endsWith(extension))
|
||||
.map(name => path.join(directory, name));
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPackageListing(packageFile, packageType, expectedPath) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
|
||||
const listing = result.stdout ?? '';
|
||||
assertSafePackageListing(listing, packageType);
|
||||
if (!listing.includes(expectedPath)) {
|
||||
fail(`${packageType} package is missing ${expectedPath}`);
|
||||
}
|
||||
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
|
||||
fail(`${packageType} package is missing its desktop entry`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageRecommendations(packageFile, packageType) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
|
||||
const recommendations = result.stdout ?? '';
|
||||
const dependencyNames = packageType === 'deb'
|
||||
? recommendations
|
||||
.split(/[,|]/)
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
|
||||
: recommendations
|
||||
.split('\n')
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]);
|
||||
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
|
||||
if (!dependencyNames.includes(dependency)) {
|
||||
fail(`${packageType} package is missing its ${dependency} recommendation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafePackageListing(listing, packageType) {
|
||||
const lines = listing.split('\n').filter(Boolean);
|
||||
const paths = packageType === 'deb'
|
||||
? lines.map(line => {
|
||||
const marker = line.indexOf('./');
|
||||
if (marker < 0) fail(`Could not parse a Debian package path: ${line}`);
|
||||
return line.slice(marker + 2);
|
||||
})
|
||||
: lines.map(line => line.replace(/^\/+/, ''));
|
||||
|
||||
for (const packagePath of paths) {
|
||||
const parts = packagePath.split('/');
|
||||
if (parts.includes('..') || (parts[0] !== 'usr' && packagePath !== 'usr')) {
|
||||
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDeb(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('dpkg-deb', ['--extract', packageFile, destination]);
|
||||
}
|
||||
|
||||
function extractRpm(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('bash', ['-o', 'pipefail', '-c', 'rpm2cpio "$1" | (cd "$2" && cpio --no-absolute-filenames -idm --quiet)', '--', packageFile, destination]);
|
||||
}
|
||||
|
||||
function readPayloadManifest(root, label) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
fail(`${label} payload manifest is missing`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} payload manifest is invalid: ${error.message}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function findPayloadRoot(root, target, label) {
|
||||
const expectedBinary = `yt-dlp-${target}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
walk(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
|
||||
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
|
||||
if (sourceManifest.target !== target) {
|
||||
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
|
||||
}
|
||||
|
||||
const packagedManifest = readPayloadManifest(packagedRoot, label);
|
||||
if (packagedManifest.target !== target) {
|
||||
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
|
||||
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
|
||||
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail(`${label} payload files differ from the provisioned engine manifest`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const packagedFile = path.join(packagedRoot, relative);
|
||||
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
|
||||
fail(`${label} payload checksum mismatch: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findExecutable(root) {
|
||||
const candidates = [
|
||||
path.join(root, 'usr', 'bin', 'firelink'),
|
||||
path.join(root, 'usr', 'bin', 'Firelink'),
|
||||
];
|
||||
const executable = candidates.find(candidate => {
|
||||
if (!fs.existsSync(candidate)) return false;
|
||||
const stat = fs.lstatSync(candidate);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
if (!executable) {
|
||||
fail(`Packaged Firelink executable was not found under ${root}`);
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function verifyExtractedPackage(packageType, packageFile, target, root) {
|
||||
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const packagedRoot = findPayloadRoot(root, target, packageType);
|
||||
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
|
||||
run(process.execPath, [
|
||||
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
|
||||
'--search-root',
|
||||
root,
|
||||
'--target',
|
||||
target,
|
||||
]);
|
||||
|
||||
const executable = findExecutable(root);
|
||||
run('xvfb-run', [
|
||||
'-a',
|
||||
process.execPath,
|
||||
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
|
||||
'--executable',
|
||||
executable,
|
||||
], { env: { APPDIR: root } });
|
||||
}
|
||||
|
||||
const target = argValue('--target');
|
||||
if (!target) fail('Pass --target <Rust target triple>.');
|
||||
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
|
||||
|
||||
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
|
||||
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
|
||||
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
|
||||
const debRoot = path.join(extractionRoot, 'deb');
|
||||
const rpmRoot = path.join(extractionRoot, 'rpm');
|
||||
|
||||
try {
|
||||
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(deb, 'deb');
|
||||
extractDeb(deb, debRoot);
|
||||
verifyExtractedPackage('deb', deb, target, debRoot);
|
||||
|
||||
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(rpm, 'rpm');
|
||||
extractRpm(rpm, rpmRoot);
|
||||
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
|
||||
console.log('Linux .deb and .rpm payload and launch verification passed.');
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user