From 3f0ab2d71473c4722cce032eb0a2213eac0c139b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 11:39:48 +0000 Subject: [PATCH 1/2] fix: preserve Unix file modes when extracting JRE zip on macOS/Windows extractZip wrote every entry with fs.createWriteStream and no mode, dropping the execute bit on all JRE binaries except bin/java (which was special-cased via chmodSync). This left lib/jspawnhelper non-executable, so any launched app that spawned a child process failed with 'posix_spawn failed, error: 0'. Read the Unix mode from the high 16 bits of externalFileAttributes and apply it via createWriteStream's mode option plus an explicit chmodSync (to bypass umask). Fixes #465 --- .../resources/ca/weblite/jdeploy/jdeploy.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js b/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js index 133c2216..c1c45ce7 100644 --- a/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js +++ b/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js @@ -160,10 +160,25 @@ function njreWrap() { zipFile.openReadStream(entry, (err, readStream) => { if (err) reject(err) + // Zip entries carry the original Unix mode in the high 16 bits + // of externalFileAttributes. Preserve it so executables (java, + // jspawnhelper, keytool, ...) keep their execute bit. Without + // this, child-process spawning fails on macOS/Windows with + // "posix_spawn failed, error: 0" because lib/jspawnhelper is + // left non-executable. + const mode = (entry.externalFileAttributes >>> 16) & 0o777 + readStream.on('end', () => { + // createWriteStream's mode is still subject to umask, so + // chmod explicitly to guarantee the stored mode is applied. + if (mode) { + try { + fs.chmodSync(entryPath, mode) + } catch (e) {} + } zipFile.readEntry() }) - readStream.pipe(fs.createWriteStream(entryPath)) + readStream.pipe(fs.createWriteStream(entryPath, mode ? { mode } : undefined)) }) } }) From 6e3ba6f697716d7d3c60a654e728f97f8fff238f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 23:39:09 +0000 Subject: [PATCH 2/2] test: cover extractZip Unix-mode preservation in jdeploy.js launcher Adds a Node integration test that lifts the real extractZip function out of the generated jdeploy.js launcher and verifies it preserves Unix execute bits when unpacking a JRE .zip (issue #465). Guards against a regression where lib/jspawnhelper and other JRE binaries lose their execute bit, breaking child-process spawning on macOS/Windows with 'posix_spawn failed, error: 0'. The test builds a mode-preserving zip via the zip CLI, extracts it, and asserts execute bits survive. Skipped on Windows (no Unix execute bit) and when node/zip are unavailable. Wired into tests/test.sh. --- tests/jdeploy-js/.gitignore | 2 + tests/jdeploy-js/extract-zip-modes.test.js | 165 +++++++++++++++++++++ tests/jdeploy-js/package.json | 12 ++ tests/jdeploy-js/test.sh | 26 ++++ tests/test.sh | 2 + 5 files changed, 207 insertions(+) create mode 100644 tests/jdeploy-js/.gitignore create mode 100644 tests/jdeploy-js/extract-zip-modes.test.js create mode 100644 tests/jdeploy-js/package.json create mode 100644 tests/jdeploy-js/test.sh diff --git a/tests/jdeploy-js/.gitignore b/tests/jdeploy-js/.gitignore new file mode 100644 index 00000000..504afef8 --- /dev/null +++ b/tests/jdeploy-js/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/tests/jdeploy-js/extract-zip-modes.test.js b/tests/jdeploy-js/extract-zip-modes.test.js new file mode 100644 index 00000000..24806c2e --- /dev/null +++ b/tests/jdeploy-js/extract-zip-modes.test.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/* + * Regression test for https://github.com/shannah/jdeploy/issues/465 + * + * The generated launcher (cli/.../jdeploy.js) downloads and unpacks the JRE at + * runtime. On macOS/Windows the JRE ships as a .zip, extracted by the + * launcher's `extractZip` function. That function used to write every entry + * with `fs.createWriteStream` and no mode, dropping the Unix execute bit on + * every JRE binary except bin/java (which was chmod'd as a special case). The + * result: lib/jspawnhelper was left non-executable and any launched app that + * spawned a child process died with "posix_spawn failed, error: 0". + * + * This test exercises the REAL `extractZip` implementation lifted straight out + * of jdeploy.js. It builds a zip that stores Unix modes (an executable file, a + * jspawnhelper stand-in, and a plain non-executable file), extracts it, and + * asserts the execute bits are preserved. If the mode-preservation is removed, + * the "executable" assertions fail. + * + * The test is a no-op on Windows (no Unix execute bit) and skips gracefully if + * the `zip` CLI is unavailable. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +let yauzl; +try { + yauzl = require('yauzl'); +} catch (e) { + console.error('yauzl is not installed. Run `npm install` in tests/jdeploy-js first.'); + process.exit(2); +} + +const JDEPLOY_JS = path.resolve( + __dirname, + '..', + '..', + 'cli', + 'src', + 'main', + 'resources', + 'ca', + 'weblite', + 'jdeploy', + 'jdeploy.js' +); + +// Pull the real `extractZip` function body out of jdeploy.js by brace matching, +// so this test tracks the shipped implementation rather than a copy of it. +function extractFunctionSource(source, name) { + const marker = 'function ' + name; + const start = source.indexOf(marker); + if (start < 0) { + throw new Error('Could not find `' + marker + '` in ' + JDEPLOY_JS); + } + let depth = 0; + let started = false; + for (let i = source.indexOf('{', start); i < source.length; i++) { + const ch = source[i]; + if (ch === '{') { + depth++; + started = true; + } else if (ch === '}') { + depth--; + if (started && depth === 0) { + return source.slice(start, i + 1); + } + } + } + throw new Error('Unbalanced braces while extracting `' + name + '`'); +} + +function loadExtractZip() { + const source = fs.readFileSync(JDEPLOY_JS, 'utf8'); + const body = extractFunctionSource(source, 'extractZip'); + // extractZip only depends on yauzl, path and fs from its surrounding scope. + return new Function('yauzl', 'path', 'fs', body + '\nreturn extractZip;')(yauzl, path, fs); +} + +function haveZipCli() { + try { + execFileSync('zip', ['--version'], { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +function isExecutable(p) { + return (fs.statSync(p).mode & 0o111) !== 0; +} + +async function main() { + if (process.platform === 'win32') { + console.log('SKIP: extract-zip-modes test does not apply on Windows (no Unix execute bit).'); + return; + } + if (!haveZipCli()) { + console.log('SKIP: `zip` CLI not available; cannot build a mode-preserving fixture.'); + return; + } + + const extractZip = loadExtractZip(); + + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'jdeploy-extractzip-')); + try { + // Build a source tree with known Unix modes. + const srcDir = path.join(workRoot, 'src'); + fs.mkdirSync(path.join(srcDir, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(srcDir, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'bin', 'java'), '#!/bin/sh\necho java\n'); + fs.writeFileSync(path.join(srcDir, 'lib', 'jspawnhelper'), 'spawnhelper\n'); + fs.writeFileSync(path.join(srcDir, 'release'), 'JAVA_VERSION="21"\n'); + fs.chmodSync(path.join(srcDir, 'bin', 'java'), 0o755); + fs.chmodSync(path.join(srcDir, 'lib', 'jspawnhelper'), 0o755); + fs.chmodSync(path.join(srcDir, 'release'), 0o644); + + // Zip it up, preserving Unix modes in the high 16 bits of externalFileAttributes. + const zipPath = path.join(workRoot, 'jre.zip'); + execFileSync('zip', ['-r', '-q', zipPath, '.'], { cwd: srcDir }); + + // extractZip deletes the archive it extracts, so hand it a throwaway copy. + const workZip = path.join(workRoot, 'work.zip'); + fs.copyFileSync(zipPath, workZip); + + const outDir = path.join(workRoot, 'out'); + fs.mkdirSync(outDir, { recursive: true }); + + await extractZip(workZip, outDir); + + const checks = [ + ['bin/java preserved execute bit', isExecutable(path.join(outDir, 'bin', 'java'))], + ['lib/jspawnhelper preserved execute bit', isExecutable(path.join(outDir, 'lib', 'jspawnhelper'))], + ['release stayed non-executable', !isExecutable(path.join(outDir, 'release'))], + ]; + + let failed = false; + for (const [name, ok] of checks) { + console.log((ok ? 'PASS' : 'FAIL') + ': ' + name); + if (!ok) failed = true; + } + + if (failed) { + console.error( + '\nextractZip did not preserve Unix file modes. Executables such as ' + + 'lib/jspawnhelper would be left non-executable, breaking child-process ' + + 'spawning on macOS/Windows (posix_spawn failed, error: 0). See issue #465.' + ); + process.exit(1); + } + + console.log('\nAll extract-zip-modes checks passed.'); + } finally { + fs.rmSync(workRoot, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(2); +}); diff --git a/tests/jdeploy-js/package.json b/tests/jdeploy-js/package.json new file mode 100644 index 00000000..ece022f2 --- /dev/null +++ b/tests/jdeploy-js/package.json @@ -0,0 +1,12 @@ +{ + "name": "jdeploy-js-tests", + "version": "1.0.0", + "private": true, + "description": "Integration tests for the generated jdeploy.js launcher (JVM download/extraction).", + "scripts": { + "test": "node extract-zip-modes.test.js" + }, + "dependencies": { + "yauzl": "^2.10.0" + } +} diff --git a/tests/jdeploy-js/test.sh b/tests/jdeploy-js/test.sh new file mode 100644 index 00000000..ee0e02c0 --- /dev/null +++ b/tests/jdeploy-js/test.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Integration tests for the generated jdeploy.js launcher. +set -e +SCRIPTPATH="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +cd "$SCRIPTPATH" + +# Windows has no Unix execute bit, so the zip-mode test does not apply there. +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + echo "Skipping jdeploy.js launcher tests on Windows." + exit 0 + ;; +esac + +if ! command -v node >/dev/null 2>&1; then + echo "Skipping jdeploy.js launcher tests: node is not installed." + exit 0 +fi + +echo "Installing jdeploy.js test dependencies..." +npm install --no-audit --no-fund --silent + +echo "Running jdeploy.js launcher tests..." +node extract-zip-modes.test.js + +echo "jdeploy.js launcher tests passed" diff --git a/tests/test.sh b/tests/test.sh index 3ecf4988..9a08b6fc 100644 --- a/tests/test.sh +++ b/tests/test.sh @@ -4,6 +4,8 @@ SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" export JDEPLOY="$SCRIPTPATH/../cli/target/jdeploy-cli-1.0-SNAPSHOT.jar" cd projects bash test.sh +cd ../jdeploy-js +bash test.sh cd ../../installer bash test.sh echo "All Tests passed" \ No newline at end of file