Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 93 additions & 5 deletions bin/npm2rpm.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const normalizeData = require('normalize-package-data');
const {npmUrl, getLockfileName, getRpmPackageName} = require('../lib/npm_helpers.js');
const {generateLockfile, lockfileDependencies} = require('../lib/lockfile.js');
const specFileGenerator = require('../lib/spec_file_generator.js');
const binaryDetector = require('../lib/binary_detector.js');
const dependencyAnalyzer = require('../lib/dependency_analyzer.js');

console.log('---- npm2rpm ----'.green.bold);
console.log('-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-'.rainbow.bgWhite);
Expand All @@ -24,6 +26,9 @@ npm2rpm
.option('-t, --template [template]', "RPM .spec template to use")
.option('-o, --output [directory]', "Directory to output files to")
.option('-p, --use-legacy-peer-deps [useLegacyPeerDeps]', "Adds --legacy-peer-deps during npm install")
.option('--check-binaries', 'Check for native binaries and WebAssembly (required for Fedora packaging)')
.option('--concurrency <number>', 'Number of parallel dependency downloads (default: 5)', parseInt)
.option('--verbose-binaries', 'Show detailed binary detection output')
.parse(process.argv);

// If a name is not provided, then npm2rpm.name defaults to calling 'commander' name() function
Expand All @@ -49,6 +54,10 @@ if (npm2rpm.useLegacyPeerDeps === undefined) {
npm2rpm.useLegacyPeerDeps = false;
}

if (npm2rpm.concurrency === undefined) {
npm2rpm.concurrency = 5;
}

const url = npmUrl(npm2rpm.name, npm2rpm.version);
console.log(' - Starting npm module download: '.bold + url );
const tmpDir = createTempDir();
Expand All @@ -60,7 +69,7 @@ tar_stream.on('error', (error) => {
console.log('Are you sure that the module name and version can be found on npmjs.org?'.bold);
}
})
tar_stream.on('finish', () => {
tar_stream.on('finish', async () => {
console.log(' - Finished extracting for '.bold + npm2rpm.name);
console.log(' - Reading package.json for '.bold + npm2rpm.name);
const npm_module = readPackageJson(path.join(tmpDir, 'package', 'package.json'),
Expand All @@ -73,21 +82,100 @@ tar_stream.on('finish', () => {
fs.mkdirSync(npm2rpm.output);
}

// Binary checking is OPT-IN via --check-binaries flag
let mainPackageBinaries = null;
let cacheDir = null; // Cache directory for dependency tarballs
let cacheDirCleanup = null; // Cleanup callback for temp cache directory
if (npm2rpm.checkBinaries) {
console.log(' - Scanning main package for binaries...'.bold);
const mainScan = binaryDetector.scanForBinaries(path.join(tmpDir, 'package'));

if (mainScan.hasBinaries) {
console.warn('');
console.warn('⚠ WARNING: Main package contains native binaries/Wasm:'.yellow);
mainScan.files.forEach(f => console.warn(' -', f));
console.warn('');
console.warn('For Fedora packaging, these binaries must be stripped before building.'.yellow);
console.warn('Generated spec file will include a %prep section to strip these binaries.'.yellow);
console.warn('');
console.warn('NOTE: If this package requires these binaries to be rebuilt:'.yellow);
console.warn(' - Ensure package.json includes proper build scripts');
console.warn(' - Add appropriate BuildRequires to the spec (e.g., node-gyp, gcc-c++)');
console.warn(' - The %build section may need manual adjustment');
console.warn('');

mainPackageBinaries = mainScan.files;
} else {
console.log(' ✓ No binaries found in main package'.green);
}
}

if (npm2rpm.strategy === 'bundle') {
console.log(' - Resolving production dependencies for '.bold + npm_module.name);
const lockfile = generateLockfile(npm_module.name, npm_module.version, npm2rpm.useLegacyPeerDeps);
const dependencies = lockfileDependencies(lockfile);
console.log(' - Resolved '.bold + dependencies.length + ' packages');

writeSpecFile(npm_module, files, dependencies, npm2rpm.release, npm2rpm.template, npm2rpm.output, npm2rpm.useLegacyPeerDeps);
let analysis;

if (npm2rpm.checkBinaries) {
console.log(' - Analyzing dependencies for native binaries...'.bold);

const cacheDirObj = tmp.dirSync({ prefix: 'npm2rpm-cache-', unsafeCleanup: true });
cacheDir = cacheDirObj.name;
cacheDirCleanup = () => cacheDirObj.removeCallback();

analysis = await dependencyAnalyzer.analyzeAndCategorizeDependencies(
dependencies.filter(([name]) => name !== npm_module.name).map(([name, version, url]) => ({name, version, url})),
npm_module,
{
concurrency: npm2rpm.concurrency,
verbose: npm2rpm.verboseBinaries,
cacheDir: cacheDir
}
);

console.log(` ✓ ${analysis.bundled.length} dependencies can be bundled`.green);
if (analysis.unbundled.length > 0) {
console.log(` ⚠ ${analysis.unbundled.length} dependencies contain binaries (will be unbundled):`.yellow);
analysis.unbundled.forEach(d => {
const depType = analysis.unbundledRuntime.includes(d) ? 'runtime' : 'dev';
console.log(` - ${d.name}@${d.version}`.yellow + ` [${depType}]`.dim);
if (npm2rpm.verboseBinaries && d.binaryFiles.length > 0) {
d.binaryFiles.slice(0, 3).forEach(f => console.log(` • ${f}`.dim));
if (d.binaryFiles.length > 3) {
console.log(` ... and ${d.binaryFiles.length - 3} more`.dim);
}
}
});
console.log('');
console.log(' These dependencies will be added as Requires/BuildRequires instead of bundled.'.yellow);
console.log(' For Fedora: these must be packaged separately as RPMs first.'.yellow);
}

if (cacheDirCleanup) {
cacheDirCleanup();
}
} else {
analysis = {
bundled: dependencies.filter(([name]) => name !== npm_module.name).map(([name, version, url]) => ({name, version, url})),
unbundled: [],
unbundledRuntime: [],
unbundledDev: []
};
}

writeSpecFile(npm_module, files, analysis, mainPackageBinaries, npm2rpm.release, npm2rpm.template, npm2rpm.output, npm2rpm.useLegacyPeerDeps);
writeLockfile(npm_module, lockfile, npm2rpm.output);
} else {
writeSpecFile(npm_module, files, [], npm2rpm.release, npm2rpm.template, npm2rpm.output, npm2rpm.useLegacyPeerDeps);
// Single strategy - pass empty analysis (no dependencies)
const analysis = { bundled: [], unbundled: [], unbundledRuntime: [], unbundledDev: [] };
writeSpecFile(npm_module, files, analysis, mainPackageBinaries, npm2rpm.release, npm2rpm.template, npm2rpm.output, npm2rpm.useLegacyPeerDeps);
}
})

function writeSpecFile(npmModule, files, dependencies, release, template, specDir, use_legacy_peer_deps) {
const content = specFileGenerator(npmModule, files, dependencies, release, template, use_legacy_peer_deps);
function writeSpecFile(npmModule, files, analysis, mainPackageBinaries, release, template, specDir, use_legacy_peer_deps) {
const content = specFileGenerator(npmModule, files, analysis, mainPackageBinaries, release, template, use_legacy_peer_deps);
const filename = path.join(specDir, `${getRpmPackageName(npmModule.name)}.spec`);
fs.writeFileSync(filename, content);
return filename;
Expand Down
31 changes: 31 additions & 0 deletions bundle.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ BuildRequires: nodejs-packaging
# before 10.3, where the nodejs major version macro does not resolve without
# node in the buildroot.
BuildRequires: /usr/bin/node
{{#each UNBUNDLED_BUILD_REQUIRES}}
BuildRequires: npm({{{.}}})
{{/each}}
BuildArch: noarch
ExclusiveArch: %{nodejs_arches} noarch

Provides: npm(%{npm_name}) = %{version}
{{#each PROVIDES}}
Provides: bundled(npm({{{name}}})) = {{{version}}}
{{/each}}
{{#each UNBUNDLED_REQUIRES}}
Requires: npm({{{.}}})
{{/each}}
AutoReq: no
AutoProv: no

Expand Down Expand Up @@ -61,8 +67,33 @@ fs.writeFileSync("package.json", JSON.stringify({
}, null, 2) + "\n");
'

# Extract Source0 to allow patching
PREP_TEMP=$(mktemp -d)
tar xzf %{SOURCE0} -C $PREP_TEMP
pushd $PREP_TEMP
# Apply patches and remove binaries here if needed
{{#if MAIN_PACKAGE_HAS_BINARIES}}
# Remove pre-compiled binaries detected in source
# Fedora policy requires building from source or excluding binaries
# The following files were detected as native binaries or WebAssembly:
{{#each PREP_BINARY_STRIP.files}}
# {{{.}}}
{{/each}}
# If these binaries are needed at runtime, ensure package.json has build scripts
# and add appropriate BuildRequires (e.g., node-gyp, gcc-c++, *-devel)

rm -vf{{#each PREP_BINARY_STRIP.files}} \
{{{.}}}{{/each}}

{{/if}}
popd
tar czf %{_builddir}/%{npm_name}-%{version}.tgz -C $PREP_TEMP package
rm -rf $PREP_TEMP

%build
npm ci {{{LEGACY_PEER_DEPS}}}--offline --cache %{_builddir}/%{npm_cache_dir} --omit optional
# Re-install the (possibly patched) main package over the lockfile version
npm install {{{LEGACY_PEER_DEPS}}}--offline --cache %{_builddir}/%{npm_cache_dir} "%{_builddir}/%{npm_name}-%{version}.tgz"

%install
mkdir -p %{buildroot}%{nodejs_sitelib}/%{npm_name}
Expand Down
184 changes: 184 additions & 0 deletions lib/binary_detector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Binary Detection Module
*
* Detects native binaries and WebAssembly modules in npm packages.
* Based on detection logic from undici-sources.sh
*
* Detection methods (in order of performance):
* 1. Extension check - fast, catches obvious cases
* 2. MIME type check - medium speed, high accuracy using 'file' command
* 3. Embedded Wasm check - slowest, catches base64-encoded Wasm
*/

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

/**
* Scan a directory for native binaries and WebAssembly modules
* @param {string} dir - Directory path to scan
* @returns {Object} - { hasBinaries: boolean, files: string[] }
*/
function scanForBinaries(dir) {
const binaryFiles = [];

// Find all files in the directory
const files = getAllFiles(dir);

for (const file of files) {
const relativePath = path.relative(dir, file);

// Check by extension (fast)
if (hasBinaryExtension(file)) {
binaryFiles.push(relativePath);
continue;
}

// Check by MIME type (medium speed, requires file command)
if (isBinaryByMimeType(file)) {
binaryFiles.push(relativePath);
continue;
}

// Check for embedded Wasm (slow, only for text-like files)
if (hasEmbeddedWasm(file)) {
binaryFiles.push(relativePath);
continue;
}
}

return {
hasBinaries: binaryFiles.length > 0,
files: binaryFiles
};
}

/**
* Recursively get all files in a directory
* @param {string} dir - Directory to search
* @returns {string[]} - Array of absolute file paths
*/
function getAllFiles(dir) {
const results = [];

function walk(currentPath) {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);

if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
results.push(fullPath);
}
}
}

walk(dir);
return results;
}

/**
* Check if a file has a binary extension
* @param {string} filepath - Path to file
* @returns {boolean}
*/
function hasBinaryExtension(filepath) {
const binaryExtensions = [
'.node', // Node.js native addon
'.so', // Linux shared library
'.dylib', // macOS dynamic library
'.dll', // Windows DLL
'.exe', // Windows executable
'.wasm', // WebAssembly module
];

const ext = path.extname(filepath).toLowerCase();

// Check exact extensions
if (binaryExtensions.includes(ext)) {
return true;
}

// Check for versioned .so files (e.g., .so.1, .so.1.2.3)
if (filepath.match(/\.so\.\d+/)) {
return true;
}

return false;
}

/**
* Check if a file is a binary using 'file' command MIME type
* @param {string} filepath - Path to file
* @returns {boolean}
*/
function isBinaryByMimeType(filepath) {
try {
// Use file command with --mime-type for clean output
// -N: don't pad filenames
// --mime-type: only show MIME type
const output = execSync(`file -N --mime-type "${filepath}"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'] // suppress stderr
}).trim();

// Output format: "filepath: mime/type"
const mimeType = output.split(':')[1]?.trim();

if (!mimeType) {
return false;
}

// MIME types for native binaries and WebAssembly
const binaryMimeTypes = [
'application/x-executable', // ELF executables
'application/x-pie-executable', // Position Independent ELF
'application/x-sharedlib', // ELF shared libraries
'application/x-mach-binary', // macOS Mach-O
'application/x-dosexec', // Windows PE/COFF (older)
'application/vnd.microsoft.portable-executable', // Windows PE (newer)
'application/wasm', // WebAssembly modules
];

return binaryMimeTypes.includes(mimeType);
} catch (error) {
// If file command fails, assume not a binary
return false;
}
}

/**
* Check if a file contains base64-encoded WebAssembly
* Wasm files start with magic bytes 0x00 0x61 0x73 0x6d
* In base64, this is "AGFzb"
* @param {string} filepath - Path to file
* @returns {boolean}
*/
function hasEmbeddedWasm(filepath) {
try {
// Only check text-like files (skip obvious binaries)
if (hasBinaryExtension(filepath)) {
return false;
}

// Read file content as text
const content = fs.readFileSync(filepath, 'utf8');

// Check for Wasm magic bytes in base64
// AGFzb is the base64 encoding of the first 4 bytes of a Wasm file
return content.includes('AGFzb');
} catch (error) {
// If file is not readable as text, it's probably a binary
// which would have been caught by other checks
return false;
}
}

module.exports = {
scanForBinaries,
hasBinaryExtension,
isBinaryByMimeType,
hasEmbeddedWasm,
};
Loading