mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
390 lines
13 KiB
JavaScript
390 lines
13 KiB
JavaScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import { Readable } from 'node:stream';
|
|
import { promisify } from 'node:util';
|
|
import { execFile } from 'node:child_process';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(scriptDir, '..');
|
|
|
|
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
|
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-runtime-manifest.json');
|
|
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
|
|
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'resources', 'bun', 'licenses');
|
|
export const STAGED_METADATA_FILE = 'metadata.json';
|
|
export const REQUIRED_LICENSE_FILES = [
|
|
'Bun-LICENSE.md',
|
|
'LGPL-2.0.txt',
|
|
'LGPL-2.1.txt',
|
|
'SOURCE.md',
|
|
'THIRD-PARTY-NOTICES.md',
|
|
];
|
|
|
|
const RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download';
|
|
const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux', 'win32']);
|
|
const ARCH_BY_BUILDER_VALUE = new Map([
|
|
[1, 'x64'],
|
|
[3, 'arm64'],
|
|
['x64', 'x64'],
|
|
['arm64', 'arm64'],
|
|
]);
|
|
|
|
function isRecord(value) {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readRequiredString(record, key, source) {
|
|
const value = record[key];
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
throw new Error(`${source} must contain a non-empty ${key} string.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readCommit(record, key, source) {
|
|
const value = readRequiredString(record, key, source);
|
|
if (!/^[a-f0-9]{40}$/.test(value)) {
|
|
throw new Error(`${source} must contain a 40-character ${key} commit.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function normalizeTarget(platform, arch) {
|
|
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
throw new Error(`Unsupported Bun runtime target platform: ${platform}`);
|
|
}
|
|
|
|
const normalizedArch = ARCH_BY_BUILDER_VALUE.get(arch);
|
|
if (!normalizedArch) {
|
|
throw new Error(`Unsupported Bun runtime target architecture for ${platform}: ${String(arch)}`);
|
|
}
|
|
if (platform === 'win32' && normalizedArch !== 'x64') {
|
|
throw new Error(`Unsupported Bun runtime target: ${platform}-${normalizedArch}`);
|
|
}
|
|
|
|
return {
|
|
platform,
|
|
arch: normalizedArch,
|
|
key: `${platform}-${normalizedArch}`,
|
|
executableName: platform === 'win32' ? 'bun.exe' : 'bun',
|
|
};
|
|
}
|
|
|
|
export function parsePackageManagerVersion(packageJson) {
|
|
if (!isRecord(packageJson)) {
|
|
throw new Error('package.json must contain a JSON object.');
|
|
}
|
|
const packageManager = readRequiredString(packageJson, 'packageManager', 'package.json');
|
|
const match = /^bun@(\d+\.\d+\.\d+)$/.exec(packageManager);
|
|
if (!match) {
|
|
throw new Error(
|
|
`package.json packageManager must pin Bun exactly, received ${packageManager}.`,
|
|
);
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
function parseArtifact(value, key) {
|
|
if (!isRecord(value)) {
|
|
throw new Error(`Bun runtime manifest artifact ${key} must be an object.`);
|
|
}
|
|
const file = readRequiredString(value, 'file', `Bun runtime manifest artifact ${key}`);
|
|
const sha256 = readRequiredString(value, 'sha256', `Bun runtime manifest artifact ${key}`);
|
|
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
|
throw new Error(`Bun runtime manifest artifact ${key} has an invalid SHA-256 digest.`);
|
|
}
|
|
if (path.basename(file) !== file || !file.endsWith('.zip')) {
|
|
throw new Error(`Bun runtime manifest artifact ${key} has an unsafe file name.`);
|
|
}
|
|
return { file, sha256 };
|
|
}
|
|
|
|
export function parseRuntimeManifest(manifest, version) {
|
|
if (!isRecord(manifest) || manifest.schemaVersion !== 1) {
|
|
throw new Error('Bun runtime manifest must use schemaVersion 1.');
|
|
}
|
|
const manifestVersion = readRequiredString(manifest, 'version', 'Bun runtime manifest');
|
|
if (manifestVersion !== version) {
|
|
throw new Error(
|
|
`Bun runtime manifest version ${manifestVersion} does not match packageManager bun@${version}.`,
|
|
);
|
|
}
|
|
if (!isRecord(manifest.artifacts)) {
|
|
throw new Error('Bun runtime manifest must contain an artifacts object.');
|
|
}
|
|
return {
|
|
version,
|
|
bunRevision: readCommit(manifest, 'bunRevision', 'Bun runtime manifest'),
|
|
releaseTagCommit: readCommit(manifest, 'releaseTagCommit', 'Bun runtime manifest'),
|
|
artifacts: manifest.artifacts,
|
|
licenseInventoryStatus: readRequiredString(
|
|
manifest,
|
|
'licenseInventoryStatus',
|
|
'Bun runtime manifest',
|
|
),
|
|
sourceManifest: readRequiredString(manifest, 'sourceManifest', 'Bun runtime manifest'),
|
|
correspondingSourceAsset: readRequiredString(
|
|
manifest,
|
|
'correspondingSourceAsset',
|
|
'Bun runtime manifest',
|
|
),
|
|
};
|
|
}
|
|
|
|
export async function loadRuntimeConfig({
|
|
packageJsonPath = DEFAULT_PACKAGE_JSON_PATH,
|
|
manifestPath = DEFAULT_MANIFEST_PATH,
|
|
} = {}) {
|
|
const [packageJsonText, manifestText] = await Promise.all([
|
|
fs.readFile(packageJsonPath, 'utf8'),
|
|
fs.readFile(manifestPath, 'utf8'),
|
|
]);
|
|
const version = parsePackageManagerVersion(JSON.parse(packageJsonText));
|
|
return parseRuntimeManifest(JSON.parse(manifestText), version);
|
|
}
|
|
|
|
export function resolveArtifact(config, platform, arch) {
|
|
const target = normalizeTarget(platform, arch);
|
|
const artifactValue = config.artifacts[target.key];
|
|
if (artifactValue === undefined) {
|
|
throw new Error(`Bun runtime manifest has no artifact for ${target.key}.`);
|
|
}
|
|
const artifact = parseArtifact(artifactValue, target.key);
|
|
return {
|
|
...target,
|
|
...artifact,
|
|
version: config.version,
|
|
url: `${RELEASE_BASE_URL}/bun-v${config.version}/${artifact.file}`,
|
|
};
|
|
}
|
|
|
|
export async function sha256File(filePath) {
|
|
const hash = createHash('sha256');
|
|
for await (const chunk of createReadStream(filePath)) {
|
|
hash.update(chunk);
|
|
}
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
export async function ensureCachedArchive(
|
|
artifact,
|
|
{ cacheDir = DEFAULT_CACHE_DIR, fetchImpl = globalThis.fetch } = {},
|
|
) {
|
|
if (typeof fetchImpl !== 'function') {
|
|
throw new Error('No fetch implementation is available to download Bun.');
|
|
}
|
|
const versionCacheDir = path.join(cacheDir, artifact.version);
|
|
const archivePath = path.join(versionCacheDir, artifact.file);
|
|
await fs.mkdir(versionCacheDir, { recursive: true });
|
|
|
|
try {
|
|
if ((await sha256File(archivePath)) === artifact.sha256) return archivePath;
|
|
await fs.unlink(archivePath);
|
|
} catch (error) {
|
|
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
|
}
|
|
|
|
const temporaryPath = `${archivePath}.download-${randomUUID()}`;
|
|
try {
|
|
const response = await fetchImpl(artifact.url);
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Unable to download ${artifact.url}: HTTP ${response.status}`);
|
|
}
|
|
await pipeline(
|
|
Readable.fromWeb(response.body),
|
|
createWriteStream(temporaryPath, { flags: 'wx' }),
|
|
);
|
|
const actualSha256 = await sha256File(temporaryPath);
|
|
if (actualSha256 !== artifact.sha256) {
|
|
throw new Error(
|
|
`Bun archive checksum mismatch for ${artifact.file}: expected ${artifact.sha256}, received ${actualSha256}.`,
|
|
);
|
|
}
|
|
await fs.rename(temporaryPath, archivePath);
|
|
return archivePath;
|
|
} catch (error) {
|
|
await fs.rm(temporaryPath, { force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isSafeZipEntry(entry) {
|
|
if (entry.startsWith('/') || /^[A-Za-z]:/.test(entry)) return false;
|
|
return !entry.replaceAll('\\', '/').split('/').includes('..');
|
|
}
|
|
|
|
function waitForProcess(child, description) {
|
|
let stderr = '';
|
|
child.stderr.setEncoding('utf8');
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
child.once('error', reject);
|
|
child.once('close', (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`${description}: ${stderr.trim() || `process exited ${code}`}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
export function buildWindowsExtractionCommand(archivePath, member, outputPath) {
|
|
const script = [
|
|
'Add-Type -AssemblyName System.IO.Compression.FileSystem',
|
|
'$archive = [IO.Compression.ZipFile]::OpenRead($env:SUBMINER_BUN_ARCHIVE_PATH)',
|
|
'try {',
|
|
' $entry = $archive.Entries | Where-Object { $_.FullName -ceq $env:SUBMINER_BUN_ARCHIVE_MEMBER }',
|
|
' if ($null -eq $entry) { throw "Archive member not found: $env:SUBMINER_BUN_ARCHIVE_MEMBER" }',
|
|
' $inputStream = $entry.Open()',
|
|
' $outputStream = [IO.File]::Create($env:SUBMINER_BUN_OUTPUT_PATH)',
|
|
' try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() }',
|
|
'} finally { $archive.Dispose() }',
|
|
].join('; ');
|
|
return {
|
|
command: 'powershell.exe',
|
|
args: [
|
|
'-NoLogo',
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-EncodedCommand',
|
|
Buffer.from(script, 'utf16le').toString('base64'),
|
|
],
|
|
environment: {
|
|
SUBMINER_BUN_ARCHIVE_PATH: archivePath,
|
|
SUBMINER_BUN_ARCHIVE_MEMBER: member,
|
|
SUBMINER_BUN_OUTPUT_PATH: outputPath,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function extractZipMemberOnWindows(archivePath, member, outputPath) {
|
|
const command = buildWindowsExtractionCommand(archivePath, member, outputPath);
|
|
const powershell = spawn(command.command, command.args, {
|
|
env: { ...process.env, ...command.environment },
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
});
|
|
await waitForProcess(powershell, `Unable to extract ${member}`);
|
|
}
|
|
|
|
export async function extractZipMember(archivePath, member, outputPath) {
|
|
if (!isSafeZipEntry(member)) {
|
|
throw new Error(`Refusing to extract unsafe Bun archive member ${member}.`);
|
|
}
|
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
const temporaryPath = `${outputPath}.extract-${randomUUID()}`;
|
|
|
|
if (process.platform === 'win32') {
|
|
try {
|
|
await extractZipMemberOnWindows(archivePath, member, temporaryPath);
|
|
await fs.chmod(temporaryPath, 0o755);
|
|
await fs.rename(temporaryPath, outputPath);
|
|
} catch (error) {
|
|
await fs.rm(temporaryPath, { force: true });
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { stdout } = await execFileAsync('unzip', ['-Z1', archivePath], {
|
|
encoding: 'utf8',
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
const entries = stdout.split(/\r?\n/).filter(Boolean);
|
|
if (entries.some((entry) => !isSafeZipEntry(entry))) {
|
|
throw new Error(`Bun archive ${archivePath} contains an unsafe path.`);
|
|
}
|
|
if (!entries.includes(member)) {
|
|
throw new Error(`Bun archive ${archivePath} does not contain ${member}.`);
|
|
}
|
|
|
|
const output = createWriteStream(temporaryPath, { flags: 'wx', mode: 0o755 });
|
|
const unzip = spawn('unzip', ['-p', archivePath, member], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
try {
|
|
await Promise.all([
|
|
pipeline(unzip.stdout, output),
|
|
waitForProcess(unzip, `Unable to extract ${member}`),
|
|
]);
|
|
await fs.chmod(temporaryPath, 0o755);
|
|
await fs.rename(temporaryPath, outputPath);
|
|
} catch (error) {
|
|
await fs.rm(temporaryPath, { force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function resolveResourcesDirectory(appOutDir, platform, productFilename = 'SubMiner') {
|
|
if (platform !== 'darwin') return path.join(appOutDir, 'resources');
|
|
const appBundlePath = appOutDir.endsWith('.app')
|
|
? appOutDir
|
|
: path.join(appOutDir, `${productFilename}.app`);
|
|
return path.join(appBundlePath, 'Contents', 'Resources');
|
|
}
|
|
|
|
export async function stageBunLicenses(
|
|
runtimeDirectory,
|
|
licensesSourceDir = DEFAULT_LICENSES_SOURCE_DIR,
|
|
) {
|
|
const licensesDirectory = path.join(runtimeDirectory, 'licenses');
|
|
await Promise.all(
|
|
REQUIRED_LICENSE_FILES.map((fileName) => fs.access(path.join(licensesSourceDir, fileName))),
|
|
);
|
|
await fs.cp(licensesSourceDir, licensesDirectory, { recursive: true, force: true });
|
|
return licensesDirectory;
|
|
}
|
|
|
|
export async function stageBunRuntime(
|
|
{ appOutDir, platform, arch, productFilename = 'SubMiner' },
|
|
{
|
|
configLoader = loadRuntimeConfig,
|
|
archiveLoader = ensureCachedArchive,
|
|
extractor = extractZipMember,
|
|
licenseStager = stageBunLicenses,
|
|
} = {},
|
|
) {
|
|
const config = await configLoader();
|
|
const artifact = resolveArtifact(config, platform, arch);
|
|
const archivePath = await archiveLoader(artifact);
|
|
const archiveDirectory = path.basename(artifact.file, '.zip');
|
|
const archiveExecutableName = platform === 'win32' ? 'bun.exe' : 'bun';
|
|
const member = `${archiveDirectory}/${archiveExecutableName}`;
|
|
const runtimeDirectory = path.join(
|
|
resolveResourcesDirectory(appOutDir, platform, productFilename),
|
|
'bun',
|
|
);
|
|
const executablePath = path.join(runtimeDirectory, artifact.executableName);
|
|
await extractor(archivePath, member, executablePath);
|
|
await licenseStager(runtimeDirectory);
|
|
|
|
const metadata = {
|
|
name: 'Bun',
|
|
version: config.version,
|
|
bunRevision: config.bunRevision,
|
|
releaseTagCommit: config.releaseTagCommit,
|
|
target: artifact.key,
|
|
artifact: artifact.file,
|
|
artifactSha256: artifact.sha256,
|
|
sourceUrl: artifact.url,
|
|
licenseInventoryStatus: config.licenseInventoryStatus,
|
|
correspondingSourceAsset: config.correspondingSourceAsset,
|
|
sourceInstructions: 'licenses/SOURCE.md',
|
|
thirdPartyNotices: 'licenses/THIRD-PARTY-NOTICES.md',
|
|
};
|
|
await fs.writeFile(
|
|
path.join(runtimeDirectory, STAGED_METADATA_FILE),
|
|
`${JSON.stringify(metadata, null, 2)}\n`,
|
|
{ mode: 0o644 },
|
|
);
|
|
return {
|
|
executablePath,
|
|
metadataPath: path.join(runtimeDirectory, STAGED_METADATA_FILE),
|
|
artifact,
|
|
};
|
|
}
|