feat(natives): added embedded addon tarball extraction in natives
- Added embedded addon tarball output in `embed-native.ts` using `embedded-addons.<platform>.tar.gz` artifacts. - Added metadata-rich addon types with `size`, `filePath`, and optional `archive` fields. - Updated extraction to prioritize archive unpacking and skip cached `.node` files when sizes match. - Added `extractEmbeddedAddonArchive()` with archive parsing and safe validation of archive entry names and kinds. - Adjusted release profile to disable line-table generation and strip symbols in `Cargo.toml`. - Added CI-only ELF validation for forbidden sections and regression coverage for issue-823 archive extraction.
This commit is contained in:
+2
-2
@@ -26,8 +26,8 @@ panic = "abort"
|
|||||||
inherits = "release"
|
inherits = "release"
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 16
|
codegen-units = 16
|
||||||
debug = "line-tables-only"
|
debug = false
|
||||||
strip = "none"
|
strip = "symbols"
|
||||||
split-debuginfo = "off"
|
split-debuginfo = "off"
|
||||||
|
|
||||||
[profile.local]
|
[profile.local]
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed native addon extraction to skip re-extracting cached `.node` files when their size already matches embedded archive metadata
|
||||||
|
- Changed standalone binaries to embed native addons as a compressed tarball and unpack them into the versioned native cache on first run instead of embedding each `.node` file uncompressed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed CI native addon builds retaining ELF debug and symbol sections in release artifacts; stripped builds are now verified to reject `.debug_*`, `.zdebug_*`, `.symtab`, and `.strtab` sections.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Hardened embedded addon archive extraction by rejecting unsafe entry names and non-file archive entries before writing binaries to disk
|
||||||
|
|
||||||
## [15.5.4] - 2026-05-27
|
## [15.5.4] - 2026-05-27
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
* @typedef {Object} EmbeddedAddonFile
|
* @typedef {Object} EmbeddedAddonFile
|
||||||
* @property {EmbeddedAddonVariant} variant
|
* @property {EmbeddedAddonVariant} variant
|
||||||
* @property {string} filename
|
* @property {string} filename
|
||||||
|
* @property {number} size
|
||||||
|
* @property {string=} filePath
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} EmbeddedAddonArchive
|
||||||
|
* @property {"tar.gz"} format
|
||||||
|
* @property {string} filename
|
||||||
* @property {string} filePath
|
* @property {string} filePath
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -16,6 +24,7 @@
|
|||||||
* @property {string} platformTag
|
* @property {string} platformTag
|
||||||
* @property {string} version
|
* @property {string} version
|
||||||
* @property {EmbeddedAddonFile[]} files
|
* @property {EmbeddedAddonFile[]} files
|
||||||
|
* @property {EmbeddedAddonArchive=} archive
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {EmbeddedAddon|null} */
|
/** @type {EmbeddedAddon|null} */
|
||||||
|
|||||||
+15
@@ -1,6 +1,13 @@
|
|||||||
export interface EmbeddedAddonFile {
|
export interface EmbeddedAddonFile {
|
||||||
variant: "modern" | "baseline" | "default";
|
variant: "modern" | "baseline" | "default";
|
||||||
filename: string;
|
filename: string;
|
||||||
|
size?: number;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmbeddedAddonArchive {
|
||||||
|
format: "tar.gz";
|
||||||
|
filename: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,6 +15,7 @@ export interface EmbeddedAddon {
|
|||||||
platformTag: string;
|
platformTag: string;
|
||||||
version: string;
|
version: string;
|
||||||
files: EmbeddedAddonFile[];
|
files: EmbeddedAddonFile[];
|
||||||
|
archive?: EmbeddedAddonArchive;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectCompiledBinaryInput {
|
export interface DetectCompiledBinaryInput {
|
||||||
@@ -46,4 +54,11 @@ export interface ResolveLoaderCandidatesInput {
|
|||||||
|
|
||||||
export function resolveLoaderCandidates(input: ResolveLoaderCandidatesInput): string[];
|
export function resolveLoaderCandidates(input: ResolveLoaderCandidatesInput): string[];
|
||||||
|
|
||||||
|
export interface ExtractEmbeddedAddonArchiveInput {
|
||||||
|
archivePath: string;
|
||||||
|
files: EmbeddedAddonFile[];
|
||||||
|
targetDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractEmbeddedAddonArchive(input: ExtractEmbeddedAddonArchiveInput): string[];
|
||||||
export function loadNative(): Record<string, unknown>;
|
export function loadNative(): Record<string, unknown>;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import * as zlib from "node:zlib";
|
||||||
import packageJson from "../package.json" with { type: "json" };
|
import packageJson from "../package.json" with { type: "json" };
|
||||||
import { embeddedAddon } from "./embedded-addon.js";
|
import { embeddedAddon } from "./embedded-addon.js";
|
||||||
|
|
||||||
@@ -228,6 +229,123 @@ function selectEmbeddedAddonFile(selectedVariant) {
|
|||||||
return embeddedAddon.files.find(file => file.variant === "baseline") || null;
|
return embeddedAddon.files.find(file => file.variant === "baseline") || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readTarString(buffer, offset, length) {
|
||||||
|
const end = Math.min(offset + length, buffer.length);
|
||||||
|
let stringEnd = offset;
|
||||||
|
while (stringEnd < end && buffer[stringEnd] !== 0) stringEnd++;
|
||||||
|
return buffer.toString("utf8", offset, stringEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTarOctal(buffer, offset, length) {
|
||||||
|
const value = readTarString(buffer, offset, length).trim();
|
||||||
|
if (!value) return 0;
|
||||||
|
const parsed = Number.parseInt(value, 8);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new Error(`Invalid tar octal value: ${value}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZeroTarBlock(buffer, offset) {
|
||||||
|
for (let index = 0; index < 512; index++) {
|
||||||
|
if (buffer[offset + index] !== 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTarEntryName(header) {
|
||||||
|
const name = readTarString(header, 0, 100);
|
||||||
|
const prefix = readTarString(header, 345, 155);
|
||||||
|
return prefix ? `${prefix}/${name}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeEmbeddedAddonFilename(filename) {
|
||||||
|
return filename.length > 0 && path.basename(filename) === filename && !filename.includes("/") && !filename.includes("\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmbeddedAddonFileCurrent(targetPath, file) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(targetPath);
|
||||||
|
if (!stat.isFile()) return false;
|
||||||
|
return typeof file.size !== "number" || stat.size === file.size;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === "ENOENT") return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEmbeddedAddonFile(targetPath, content) {
|
||||||
|
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tempPath, content, { mode: 0o755 });
|
||||||
|
fs.renameSync(tempPath, targetPath);
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup only.
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractEmbeddedAddonArchive({ archivePath, files, targetDir }) {
|
||||||
|
const pending = new Map();
|
||||||
|
for (const file of files) {
|
||||||
|
if (!isSafeEmbeddedAddonFilename(file.filename)) {
|
||||||
|
throw new Error(`Unsafe embedded addon filename: ${file.filename}`);
|
||||||
|
}
|
||||||
|
const targetPath = path.join(targetDir, file.filename);
|
||||||
|
if (!isEmbeddedAddonFileCurrent(targetPath, file)) {
|
||||||
|
pending.set(file.filename, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.size === 0) return [];
|
||||||
|
|
||||||
|
const archive = zlib.gunzipSync(fs.readFileSync(archivePath));
|
||||||
|
const writtenPaths = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 512 <= archive.length) {
|
||||||
|
if (isZeroTarBlock(archive, offset)) break;
|
||||||
|
const header = archive.subarray(offset, offset + 512);
|
||||||
|
const filename = getTarEntryName(header);
|
||||||
|
const size = readTarOctal(header, 124, 12);
|
||||||
|
const typeflag = header[156] === 0 ? "0" : String.fromCharCode(header[156]);
|
||||||
|
offset += 512;
|
||||||
|
|
||||||
|
if (offset + size > archive.length) {
|
||||||
|
throw new Error(`Truncated embedded addon archive entry: ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSafeEmbeddedAddonFilename(filename)) {
|
||||||
|
throw new Error(`Unsafe embedded addon archive entry: ${filename}`);
|
||||||
|
}
|
||||||
|
if (typeflag !== "0") {
|
||||||
|
throw new Error(`Unsupported embedded addon archive entry type ${typeflag}: ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = pending.get(filename);
|
||||||
|
if (file) {
|
||||||
|
if (typeof file.size === "number" && file.size !== size) {
|
||||||
|
throw new Error(`Embedded addon size mismatch for ${filename}: expected ${file.size}, got ${size}`);
|
||||||
|
}
|
||||||
|
const targetPath = path.join(targetDir, filename);
|
||||||
|
writeEmbeddedAddonFile(targetPath, archive.subarray(offset, offset + size));
|
||||||
|
pending.delete(filename);
|
||||||
|
writtenPaths.push(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += Math.ceil(size / 512) * 512;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.size > 0) {
|
||||||
|
throw new Error(`Embedded addon archive missing: ${[...pending.keys()].join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return writtenPaths;
|
||||||
|
}
|
||||||
|
|
||||||
function maybeExtractEmbeddedAddon(ctx, errors) {
|
function maybeExtractEmbeddedAddon(ctx, errors) {
|
||||||
if (!ctx.isCompiledBinary || !embeddedAddon) return null;
|
if (!ctx.isCompiledBinary || !embeddedAddon) return null;
|
||||||
if (embeddedAddon.platformTag !== ctx.platformTag || embeddedAddon.version !== ctx.packageVersion) return null;
|
if (embeddedAddon.platformTag !== ctx.platformTag || embeddedAddon.version !== ctx.packageVersion) return null;
|
||||||
@@ -244,9 +362,32 @@ function maybeExtractEmbeddedAddon(ctx, errors) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(targetPath)) {
|
if (embeddedAddon.archive) {
|
||||||
|
try {
|
||||||
|
extractEmbeddedAddonArchive({
|
||||||
|
archivePath: embeddedAddon.archive.filePath,
|
||||||
|
files: embeddedAddon.files,
|
||||||
|
targetDir: ctx.versionedDir,
|
||||||
|
});
|
||||||
|
if (isEmbeddedAddonFileCurrent(targetPath, selectedEmbeddedFile)) {
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
errors.push(`embedded addon archive (${embeddedAddon.archive.filename}): missing ${selectedEmbeddedFile.filename}`);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push(`embedded addon archive (${embeddedAddon.archive.filename}): ${message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmbeddedAddonFileCurrent(targetPath, selectedEmbeddedFile)) {
|
||||||
return targetPath;
|
return targetPath;
|
||||||
}
|
}
|
||||||
|
if (!selectedEmbeddedFile.filePath) {
|
||||||
|
errors.push(`embedded addon metadata missing file path for ${selectedEmbeddedFile.filename}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = fs.readFileSync(selectedEmbeddedFile.filePath);
|
const buffer = fs.readFileSync(selectedEmbeddedFile.filePath);
|
||||||
|
|||||||
@@ -145,6 +145,134 @@ async function installGeneratedBindings(outputDir: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isElfFile(filePath: string): Promise<boolean> {
|
||||||
|
const handle = await fs.open(filePath, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(4);
|
||||||
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||||
|
return bytesRead === 4 && buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readElfUint(buffer: Buffer, offset: number, byteLength: 2 | 4 | 8, littleEndian: boolean): number {
|
||||||
|
if (offset < 0 || offset + byteLength > buffer.length) {
|
||||||
|
throw new Error("ELF section table is truncated.");
|
||||||
|
}
|
||||||
|
if (byteLength === 2) return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
|
||||||
|
if (byteLength === 4) return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
|
||||||
|
const value = littleEndian ? buffer.readBigUInt64LE(offset) : buffer.readBigUInt64BE(offset);
|
||||||
|
const numberValue = Number(value);
|
||||||
|
if (!Number.isSafeInteger(numberValue)) {
|
||||||
|
throw new Error(`ELF integer exceeds JavaScript's safe range: ${value}`);
|
||||||
|
}
|
||||||
|
return numberValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCString(buffer: Buffer, offset: number): string {
|
||||||
|
if (offset < 0 || offset >= buffer.length) return "";
|
||||||
|
let end = offset;
|
||||||
|
while (end < buffer.length && buffer[end] !== 0) end++;
|
||||||
|
return buffer.toString("utf8", offset, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readElfSectionNames(buffer: Buffer): string[] {
|
||||||
|
if (buffer.length < 0x40 || buffer[0] !== 0x7f || buffer[1] !== 0x45 || buffer[2] !== 0x4c || buffer[3] !== 0x46) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const elfClass = buffer[4];
|
||||||
|
const endian = buffer[5];
|
||||||
|
if (elfClass !== 1 && elfClass !== 2) throw new Error(`Unsupported ELF class: ${elfClass}`);
|
||||||
|
if (endian !== 1 && endian !== 2) throw new Error(`Unsupported ELF endian marker: ${endian}`);
|
||||||
|
|
||||||
|
const is64Bit = elfClass === 2;
|
||||||
|
const littleEndian = endian === 1;
|
||||||
|
const sectionHeaderOffset = is64Bit
|
||||||
|
? readElfUint(buffer, 0x28, 8, littleEndian)
|
||||||
|
: readElfUint(buffer, 0x20, 4, littleEndian);
|
||||||
|
const sectionHeaderEntrySize = readElfUint(buffer, is64Bit ? 0x3a : 0x2e, 2, littleEndian);
|
||||||
|
const sectionHeaderCount = readElfUint(buffer, is64Bit ? 0x3c : 0x30, 2, littleEndian);
|
||||||
|
const sectionNameTableIndex = readElfUint(buffer, is64Bit ? 0x3e : 0x32, 2, littleEndian);
|
||||||
|
if (sectionHeaderOffset === 0 || sectionHeaderCount === 0) return [];
|
||||||
|
if (sectionNameTableIndex >= sectionHeaderCount) {
|
||||||
|
throw new Error("ELF section name table index is out of bounds.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionHeadersEnd = sectionHeaderOffset + sectionHeaderEntrySize * sectionHeaderCount;
|
||||||
|
if (sectionHeadersEnd > buffer.length) {
|
||||||
|
throw new Error("ELF section headers extend past the end of the file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionHeader = (index: number) => sectionHeaderOffset + sectionHeaderEntrySize * index;
|
||||||
|
const sectionNameTableHeader = sectionHeader(sectionNameTableIndex);
|
||||||
|
const nameTableOffset = readElfUint(
|
||||||
|
buffer,
|
||||||
|
sectionNameTableHeader + (is64Bit ? 0x18 : 0x10),
|
||||||
|
is64Bit ? 8 : 4,
|
||||||
|
littleEndian,
|
||||||
|
);
|
||||||
|
const nameTableSize = readElfUint(
|
||||||
|
buffer,
|
||||||
|
sectionNameTableHeader + (is64Bit ? 0x20 : 0x14),
|
||||||
|
is64Bit ? 8 : 4,
|
||||||
|
littleEndian,
|
||||||
|
);
|
||||||
|
if (nameTableOffset + nameTableSize > buffer.length) {
|
||||||
|
throw new Error("ELF section name table extends past the end of the file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const names: string[] = [];
|
||||||
|
const nameTable = buffer.subarray(nameTableOffset, nameTableOffset + nameTableSize);
|
||||||
|
for (let index = 0; index < sectionHeaderCount; index++) {
|
||||||
|
const nameOffset = readElfUint(buffer, sectionHeader(index), 4, littleEndian);
|
||||||
|
names.push(readCString(nameTable, nameOffset));
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbiddenStrippedElfSections = new Set([".symtab", ".strtab"]);
|
||||||
|
|
||||||
|
function getForbiddenElfSections(sectionNames: string[]): string[] {
|
||||||
|
return sectionNames.filter(
|
||||||
|
sectionName =>
|
||||||
|
forbiddenStrippedElfSections.has(sectionName) ||
|
||||||
|
sectionName.startsWith(".debug_") ||
|
||||||
|
sectionName.startsWith(".zdebug_"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStripTool(addonPath: string): Promise<void> {
|
||||||
|
const toolSpecs = [
|
||||||
|
{ command: "llvm-strip", args: ["--strip-unneeded"] },
|
||||||
|
{ command: "strip", args: ["--strip-unneeded"] },
|
||||||
|
];
|
||||||
|
for (const tool of toolSpecs) {
|
||||||
|
const executable = Bun.which(tool.command);
|
||||||
|
if (!executable) continue;
|
||||||
|
const proc = Bun.spawn([executable, ...tool.args, addonPath], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
if (exitCode === 0) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stripAndVerifyNativeAddon(addonPath: string): Promise<void> {
|
||||||
|
if (profileLabel !== "ci") return;
|
||||||
|
if (!(await isElfFile(addonPath))) return;
|
||||||
|
|
||||||
|
await runStripTool(addonPath);
|
||||||
|
const sectionNames = readElfSectionNames(await fs.readFile(addonPath));
|
||||||
|
const forbiddenSections = getForbiddenElfSections(sectionNames);
|
||||||
|
if (forbiddenSections.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Native addon ${path.basename(addonPath)} still contains stripped-release forbidden ELF sections: ${forbiddenSections.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isCI = Boolean(Bun.env.CI);
|
const isCI = Boolean(Bun.env.CI);
|
||||||
const useLocalProfile = !isCI && !isCrossCompile;
|
const useLocalProfile = !isCI && !isCrossCompile;
|
||||||
const profileLabel = useLocalProfile ? "local" : "ci";
|
const profileLabel = useLocalProfile ? "local" : "ci";
|
||||||
@@ -213,6 +341,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const builtAddonPath = await resolveBuiltAddonPath(buildOutputDir, canonicalAddonFilename);
|
const builtAddonPath = await resolveBuiltAddonPath(buildOutputDir, canonicalAddonFilename);
|
||||||
|
await stripAndVerifyNativeAddon(builtAddonPath);
|
||||||
if (builtAddonPath !== canonicalAddonPath) {
|
if (builtAddonPath !== canonicalAddonPath) {
|
||||||
console.log(`Normalizing native addon filename: ${path.basename(builtAddonPath)} → ${canonicalAddonFilename}`);
|
console.log(`Normalizing native addon filename: ${path.basename(builtAddonPath)} → ${canonicalAddonFilename}`);
|
||||||
await installBinary(builtAddonPath, canonicalAddonPath);
|
await installBinary(builtAddonPath, canonicalAddonPath);
|
||||||
|
|||||||
@@ -5,17 +5,23 @@ const reset = process.argv.includes("--reset");
|
|||||||
const outputPath = path.join(import.meta.dir, "../native/embedded-addon.js");
|
const outputPath = path.join(import.meta.dir, "../native/embedded-addon.js");
|
||||||
const packageJsonPath = path.join(import.meta.dir, "../package.json");
|
const packageJsonPath = path.join(import.meta.dir, "../package.json");
|
||||||
const nativeDir = path.join(import.meta.dir, "../native");
|
const nativeDir = path.join(import.meta.dir, "../native");
|
||||||
|
const archivePrefix = "embedded-addons.";
|
||||||
|
const archiveSuffix = ".tar.gz";
|
||||||
|
|
||||||
const stubContent = `
|
const embeddedAddonTypedefs = `/** @typedef {"modern" | "baseline" | "default"} EmbeddedAddonVariant */
|
||||||
// AUTOGENERATED FILE -- DO NOT EDIT DIRECTLY
|
|
||||||
// See scripts/embed-native.ts
|
|
||||||
|
|
||||||
/** @typedef {"modern" | "baseline" | "default"} EmbeddedAddonVariant */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} EmbeddedAddonFile
|
* @typedef {Object} EmbeddedAddonFile
|
||||||
* @property {EmbeddedAddonVariant} variant
|
* @property {EmbeddedAddonVariant} variant
|
||||||
* @property {string} filename
|
* @property {string} filename
|
||||||
|
* @property {number} size
|
||||||
|
* @property {string=} filePath
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} EmbeddedAddonArchive
|
||||||
|
* @property {"tar.gz"} format
|
||||||
|
* @property {string} filename
|
||||||
* @property {string} filePath
|
* @property {string} filePath
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,13 +30,30 @@ const stubContent = `
|
|||||||
* @property {string} platformTag
|
* @property {string} platformTag
|
||||||
* @property {string} version
|
* @property {string} version
|
||||||
* @property {EmbeddedAddonFile[]} files
|
* @property {EmbeddedAddonFile[]} files
|
||||||
*/
|
* @property {EmbeddedAddonArchive=} archive
|
||||||
|
*/`;
|
||||||
|
|
||||||
|
const stubContent = `
|
||||||
|
// AUTOGENERATED FILE -- DO NOT EDIT DIRECTLY
|
||||||
|
// See scripts/embed-native.ts
|
||||||
|
|
||||||
|
${embeddedAddonTypedefs}
|
||||||
|
|
||||||
/** @type {EmbeddedAddon|null} */
|
/** @type {EmbeddedAddon|null} */
|
||||||
export const embeddedAddon = null;
|
export const embeddedAddon = null;
|
||||||
`;
|
`;
|
||||||
if (reset) {
|
if (reset) {
|
||||||
await Bun.write(outputPath, stubContent);
|
await Bun.write(outputPath, stubContent);
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(nativeDir);
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter(entry => entry.startsWith(archivePrefix) && entry.endsWith(archiveSuffix))
|
||||||
|
.map(entry => fs.unlink(path.join(nativeDir, entry))),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,15 +62,11 @@ interface CandidateAddon {
|
|||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fileExists(filePath: string): Promise<boolean> {
|
interface AvailableAddon extends CandidateAddon {
|
||||||
try {
|
path: string;
|
||||||
await fs.stat(filePath);
|
size: number;
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPlatform = Bun.env.TARGET_PLATFORM || process.platform;
|
const targetPlatform = Bun.env.TARGET_PLATFORM || process.platform;
|
||||||
const targetArch = Bun.env.TARGET_ARCH || process.arch;
|
const targetArch = Bun.env.TARGET_ARCH || process.arch;
|
||||||
const platformTag = `${targetPlatform}-${targetArch}`;
|
const platformTag = `${targetPlatform}-${targetArch}`;
|
||||||
@@ -59,11 +78,14 @@ const candidates: CandidateAddon[] =
|
|||||||
]
|
]
|
||||||
: [{ variant: "default", filename: `pi_natives.${platformTag}.node` }];
|
: [{ variant: "default", filename: `pi_natives.${platformTag}.node` }];
|
||||||
|
|
||||||
const available: CandidateAddon[] = [];
|
const available: AvailableAddon[] = [];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const candidatePath = path.join(nativeDir, candidate.filename);
|
const candidatePath = path.join(nativeDir, candidate.filename);
|
||||||
if (await fileExists(candidatePath)) {
|
try {
|
||||||
available.push(candidate);
|
const stat = await fs.stat(candidatePath);
|
||||||
|
available.push({ ...candidate, path: candidatePath, size: stat.size });
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,17 +95,18 @@ if (available.length === 0) {
|
|||||||
}
|
}
|
||||||
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
|
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
|
||||||
|
|
||||||
const imports = available
|
const archiveFilename = `${archivePrefix}${platformTag}${archiveSuffix}`;
|
||||||
.map(
|
const archivePath = path.join(nativeDir, archiveFilename);
|
||||||
(candidate, index) =>
|
const archiveEntries: Record<string, Uint8Array> = {};
|
||||||
`import addonPath${index} from ${JSON.stringify(`../native/${candidate.filename}`)} with { type: "file" };`,
|
for (const addon of available) {
|
||||||
)
|
archiveEntries[addon.filename] = await fs.readFile(addon.path);
|
||||||
.join("\n");
|
}
|
||||||
|
await Bun.write(archivePath, await new Bun.Archive(archiveEntries, { compress: "gzip", level: 9 }).bytes());
|
||||||
|
|
||||||
const files = available
|
const files = available
|
||||||
.map(
|
.map(
|
||||||
(candidate, index) =>
|
addon =>
|
||||||
`\t\t{ variant: ${JSON.stringify(candidate.variant)}, filename: ${JSON.stringify(candidate.filename)}, filePath: addonPath${index} },`,
|
`\t\t{ variant: ${JSON.stringify(addon.variant)}, filename: ${JSON.stringify(addon.filename)}, size: ${addon.size} },`,
|
||||||
)
|
)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
@@ -91,27 +114,18 @@ const content = `
|
|||||||
// AUTOGENERATED FILE -- DO NOT EDIT DIRECTLY
|
// AUTOGENERATED FILE -- DO NOT EDIT DIRECTLY
|
||||||
// See scripts/embed-native.ts
|
// See scripts/embed-native.ts
|
||||||
|
|
||||||
/** @typedef {"modern" | "baseline" | "default"} EmbeddedAddonVariant */
|
${embeddedAddonTypedefs}
|
||||||
|
|
||||||
/**
|
import archivePath from ${JSON.stringify(`../native/${archiveFilename}`)} with { type: "file" };
|
||||||
* @typedef {Object} EmbeddedAddonFile
|
|
||||||
* @property {EmbeddedAddonVariant} variant
|
|
||||||
* @property {string} filename
|
|
||||||
* @property {string} filePath
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} EmbeddedAddon
|
|
||||||
* @property {string} platformTag
|
|
||||||
* @property {string} version
|
|
||||||
* @property {EmbeddedAddonFile[]} files
|
|
||||||
*/
|
|
||||||
|
|
||||||
${imports}
|
|
||||||
|
|
||||||
export const embeddedAddon = {
|
export const embeddedAddon = {
|
||||||
\tplatformTag: ${JSON.stringify(platformTag)},
|
\tplatformTag: ${JSON.stringify(platformTag)},
|
||||||
\tversion: ${JSON.stringify(packageJson.version)},
|
\tversion: ${JSON.stringify(packageJson.version)},
|
||||||
|
\tarchive: {
|
||||||
|
\t\tformat: "tar.gz",
|
||||||
|
\t\tfilename: ${JSON.stringify(archiveFilename)},
|
||||||
|
\t\tfilePath: archivePath,
|
||||||
|
\t},
|
||||||
\tfiles: [
|
\tfiles: [
|
||||||
${files}
|
${files}
|
||||||
\t],
|
\t],
|
||||||
|
|||||||
@@ -26,8 +26,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { detectCompiledBinary, getAddonFilenames, resolveLoaderCandidates } from "../native/loader-state.js";
|
import {
|
||||||
|
detectCompiledBinary,
|
||||||
|
type EmbeddedAddonFile,
|
||||||
|
extractEmbeddedAddonArchive,
|
||||||
|
getAddonFilenames,
|
||||||
|
resolveLoaderCandidates,
|
||||||
|
} from "../native/loader-state.js";
|
||||||
|
|
||||||
describe("issue 823: standalone-binary native loader path resolution", () => {
|
describe("issue 823: standalone-binary native loader path resolution", () => {
|
||||||
it("detects compiled-binary mode from embedded-addon presence when env and url markers are absent", () => {
|
it("detects compiled-binary mode from embedded-addon presence when env and url markers are absent", () => {
|
||||||
@@ -129,4 +137,42 @@ describe("issue 823: standalone-binary native loader path resolution", () => {
|
|||||||
expect(candidates).not.toContain(path.join(versionedDir, "pi_natives.linux-x64-baseline.node"));
|
expect(candidates).not.toContain(path.join(versionedDir, "pi_natives.linux-x64-baseline.node"));
|
||||||
expect(candidates).not.toContain(path.join(userDataDir, "pi_natives.linux-x64-baseline.node"));
|
expect(candidates).not.toContain(path.join(userDataDir, "pi_natives.linux-x64-baseline.node"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts all bundled native variants from one gzip archive and skips current files", async () => {
|
||||||
|
const testDir = await fs.mkdtemp(path.join(os.tmpdir(), "natives-embedded-archive-"));
|
||||||
|
try {
|
||||||
|
const archivePath = path.join(testDir, "embedded-addons.linux-x64.tar.gz");
|
||||||
|
const targetDir = path.join(testDir, "cache");
|
||||||
|
await fs.mkdir(targetDir);
|
||||||
|
|
||||||
|
const modern = Buffer.from("modern native addon");
|
||||||
|
const baseline = Buffer.from("baseline native addon");
|
||||||
|
const modernFilename = "pi_natives.linux-x64-modern.node";
|
||||||
|
const baselineFilename = "pi_natives.linux-x64-baseline.node";
|
||||||
|
await Bun.write(
|
||||||
|
archivePath,
|
||||||
|
await new Bun.Archive(
|
||||||
|
{
|
||||||
|
[modernFilename]: modern,
|
||||||
|
[baselineFilename]: baseline,
|
||||||
|
},
|
||||||
|
{ compress: "gzip", level: 9 },
|
||||||
|
).bytes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const files: EmbeddedAddonFile[] = [
|
||||||
|
{ variant: "modern", filename: modernFilename, size: modern.length },
|
||||||
|
{ variant: "baseline", filename: baselineFilename, size: baseline.length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const written = extractEmbeddedAddonArchive({ archivePath, files, targetDir });
|
||||||
|
expect(written.map(filePath => path.basename(filePath)).sort()).toEqual([baselineFilename, modernFilename]);
|
||||||
|
expect(await fs.readFile(path.join(targetDir, modernFilename), "utf8")).toBe("modern native addon");
|
||||||
|
expect(await fs.readFile(path.join(targetDir, baselineFilename), "utf8")).toBe("baseline native addon");
|
||||||
|
|
||||||
|
expect(extractEmbeddedAddonArchive({ archivePath, files, targetDir })).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user