diff --git a/build.gradle b/build.gradle index b50596e616..e9f514c677 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,16 @@ application { mainClass = 'org.perlonjava.app.cli.Main' } +// $^X must be a native executable when generated Perl scripts are invoked +// directly. macOS does not reliably chain a #! script whose interpreter is +// itself the shell-based jperl launcher. +tasks.register('nativeJperlLauncher', Exec) { + onlyIf { !org.gradle.internal.os.OperatingSystem.current().isWindows() } + inputs.file('native/jperl-exec.c') + outputs.file("$buildDir/../target/jperl-exec") + commandLine 'cc', '-O2', '-o', file("$buildDir/../target/jperl-exec"), file('native/jperl-exec.c') +} + // Debian package build dependency tasks.buildDeb { dependsOn installDist @@ -36,7 +46,7 @@ tasks.buildDeb { // Copy custom wrapper scripts to installDist bin directory tasks.register('copyWrapperScripts', Copy) { - dependsOn installDist + dependsOn installDist, nativeJperlLauncher from(projectDir) { include 'jperl' include 'jperl.bat' @@ -47,6 +57,7 @@ tasks.register('copyWrapperScripts', Copy) { include 'jprove' include 'jprove.bat' } + from(file("$buildDir/../target/jperl-exec")) into "${buildDir}/install/perlonjava/bin" } @@ -494,11 +505,14 @@ tasks.register('verifyJoniPackaging', Exec) { // children go through the repository launcher, so the target jar must be built // before tests run; otherwise a stale target/perlonjava-*.jar can be executed. tasks.withType(Test).configureEach { t -> - t.dependsOn shadowJar + t.dependsOn shadowJar, nativeJperlLauncher def jperlLauncher = org.gradle.internal.os.OperatingSystem.current().isWindows() ? "jperl.bat" : "jperl" t.environment 'PERLONJAVA_EXECUTABLE', file(jperlLauncher).absolutePath + if (!org.gradle.internal.os.OperatingSystem.current().isWindows()) { + t.environment 'PERLONJAVA_SHEBANG_EXECUTABLE', file("$buildDir/../target/jperl-exec").absolutePath + } } // Task to generate Perl SBOM diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 6f7b18e3e5..c39b22886a 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -6,6 +6,8 @@ priorities and future plans. ## Work in progress +- Fix direct execution of scripts generated with a PerlOnJava `$^X` shebang and update the Java-backed `Compress::Raw::{Bzip2,Zlib}` providers to the audited 2.224 compatibility level. + - Restore Perl-compatible integer increment/decrement semantics, imprecision warnings, numeric overload fallback, and postfix-reference lifetime handling. diff --git a/jperl b/jperl index 27e095e52f..00ce68aa06 100755 --- a/jperl +++ b/jperl @@ -22,6 +22,14 @@ JPERL_PATH="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$SCRIPT_DIR/j # Export environment variable for PerlOnJava to use as $^X export PERLONJAVA_EXECUTABLE="$JPERL_PATH" +# Use the native shim for $^X when it has been built. This allows a generated +# '#!$^X' script to run directly on platforms that reject chained scripts. +if [ -x "$SCRIPT_DIR/jperl-exec" ]; then + export PERLONJAVA_SHEBANG_EXECUTABLE="$SCRIPT_DIR/jperl-exec" +elif [ -x "$SCRIPT_DIR/target/jperl-exec" ]; then + export PERLONJAVA_SHEBANG_EXECUTABLE="$SCRIPT_DIR/target/jperl-exec" +fi + # Java 24 virtual threads are the default Perl ithread carrier. Preserve an # explicit environment selection; the equivalent JVM property in JPERL_OPTS # still takes precedence inside PerlThreadExecutionPolicy. diff --git a/native/jperl-exec.c b/native/jperl-exec.c new file mode 100644 index 0000000000..680c87594c --- /dev/null +++ b/native/jperl-exec.c @@ -0,0 +1,55 @@ +/* + * Small POSIX executable shim used as PerlOnJava's $^X. + * + * A script whose shebang names the shell-based jperl launcher cannot be + * executed directly on macOS: the kernel does not reliably chain script + * interpreters. This binary is the first interpreter instead, and in turn + * starts the regular launcher under bash. + */ +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + const char *launcher = getenv("PERLONJAVA_EXECUTABLE"); + char fallback[PATH_MAX]; + + if ((launcher == NULL || launcher[0] == '\0') && strchr(argv[0], '/') != NULL) { + snprintf(fallback, sizeof(fallback), "%s", argv[0]); + char *slash = strrchr(fallback, '/'); + *slash = '\0'; + size_t directoryLength = strlen(fallback); + snprintf(fallback + directoryLength, sizeof(fallback) - directoryLength, "/jperl"); + if (access(fallback, X_OK) != 0) { + // Development builds keep the shim in target/ beside the repository launcher. + slash = strrchr(fallback, '/'); + *slash = '\0'; + slash = strrchr(fallback, '/'); + if (slash != NULL) { + *slash = '\0'; + size_t parentLength = strlen(fallback); + snprintf(fallback + parentLength, sizeof(fallback) - parentLength, "/jperl"); + } + } + launcher = fallback; + } + if (launcher == NULL || launcher[0] == '\0') { + fputs("jperl-exec: PERLONJAVA_EXECUTABLE is not set\n", stderr); + return 127; + } + + char **command = calloc((size_t)argc + 2, sizeof(*command)); + if (command == NULL) { + perror("jperl-exec: calloc"); + return 127; + } + command[0] = "/bin/bash"; + command[1] = (char *) launcher; + for (int i = 1; i < argc; i++) command[i + 1] = argv[i]; + execv(command[0], command); + fprintf(stderr, "jperl-exec: cannot start %s: %s\n", launcher, strerror(errno)); + return 127; +} diff --git a/src/main/java/org/perlonjava/app/cli/ArgumentParser.java b/src/main/java/org/perlonjava/app/cli/ArgumentParser.java index d5b253b080..44328c91a0 100644 --- a/src/main/java/org/perlonjava/app/cli/ArgumentParser.java +++ b/src/main/java/org/perlonjava/app/cli/ArgumentParser.java @@ -445,13 +445,20 @@ private static boolean interpreterScriptUsesPerlOnJava(java.nio.file.Path script } /** - * True when {@code interpreterPath} resolves to the same file as {@code PERLONJAVA_EXECUTABLE}. + * True when {@code interpreterPath} resolves to this runtime's regular launcher or its native shebang shim. */ private static boolean isPerlOnJavaExecutable(java.nio.file.Path interpreterPath) { String self = System.getenv("PERLONJAVA_EXECUTABLE"); - if (self == null || self.isEmpty()) { + String shebangSelf = System.getenv("PERLONJAVA_SHEBANG_EXECUTABLE"); + if ((self == null || self.isEmpty()) && (shebangSelf == null || shebangSelf.isEmpty())) { return false; } + return isSameExecutable(interpreterPath, self) + || isSameExecutable(interpreterPath, shebangSelf); + } + + private static boolean isSameExecutable(java.nio.file.Path interpreterPath, String self) { + if (self == null || self.isEmpty()) return false; try { java.nio.file.Path a = interpreterPath.toAbsolutePath().normalize().toRealPath(); java.nio.file.Path b = Paths.get(self).toAbsolutePath().normalize().toRealPath(); diff --git a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java index 063cf6fce6..06c9828924 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java @@ -577,8 +577,14 @@ private static List expandJperlShebangForProcessBuilder(List com } List expanded = new ArrayList<>(); - expanded.add("/bin/bash"); - expanded.add(shebangWords.getFirst()); + if (isCurrentNativeShebangShim(shebangWords.getFirst())) { + // The shim is already a real executable. Running it through bash + // would make bash attempt to parse its binary bytes as a script. + expanded.add(shebangWords.getFirst()); + } else { + expanded.add("/bin/bash"); + expanded.add(shebangWords.getFirst()); + } expanded.addAll(shebangWords.subList(1, shebangWords.size())); expanded.add(script.getAbsolutePath()); expanded.addAll(commandArgs.subList(1, commandArgs.size())); @@ -612,6 +618,18 @@ private static boolean isCurrentJperlWrapper(String interpreter) { return isCurrentJperlWrapper(interpreter, getCurrentJperlPath()); } + private static boolean isCurrentNativeShebangShim(String interpreter) { + String shim = System.getenv("PERLONJAVA_SHEBANG_EXECUTABLE"); + if (shim == null || shim.isEmpty()) return false; + try { + return new File(interpreter).getCanonicalFile() + .equals(new File(shim).getCanonicalFile()); + } catch (IOException e) { + return new File(interpreter).getAbsolutePath() + .equals(new File(shim).getAbsolutePath()); + } + } + private static boolean isCurrentJperlWrapper( String interpreter, String currentJperlPath) { if (interpreter == null || interpreter.isEmpty()) { diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawBzip2.java b/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawBzip2.java index 9f35c7e0b4..bfb3e0a6f6 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawBzip2.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawBzip2.java @@ -22,7 +22,7 @@ */ public class CompressRawBzip2 extends PerlModuleBase { - public static String XS_VERSION = "2.218"; + public static String XS_VERSION = "2.224"; private static final int BZ_OK = 0; private static final int BZ_RUN_OK = 1; diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawZlib.java b/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawZlib.java index 94b2b69f10..3aa85f9cdc 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawZlib.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/CompressRawZlib.java @@ -174,7 +174,7 @@ public static void initialize() { // Set $XS_VERSION for version check in CPAN .pm GlobalVariable.getGlobalVariable("Compress::Raw::Zlib::XS_VERSION") - .set(new RuntimeScalar("2.222")); + .set(new RuntimeScalar("2.224")); } /** diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index 0b52b13837..3cc05aa6ac 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -92,7 +92,10 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { // Initialize $^X - the name used to execute the current copy of Perl // PERLONJAVA_EXECUTABLE is set by the `jperl` or `jperl.bat` launcher - String perlExecutable = System.getenv("PERLONJAVA_EXECUTABLE"); + String perlExecutable = System.getenv("PERLONJAVA_SHEBANG_EXECUTABLE"); + if (perlExecutable == null || perlExecutable.isEmpty()) { + perlExecutable = System.getenv("PERLONJAVA_EXECUTABLE"); + } RuntimeScalar executableVariable = GlobalVariable.getGlobalVariable("main::" + Character.toString('X' - 'A' + 1)); if (perlExecutable != null && !perlExecutable.isEmpty()) { diff --git a/src/main/perl/lib/Compress/Raw/Bzip2.pm b/src/main/perl/lib/Compress/Raw/Bzip2.pm index a74a9a74c6..8148c5edc9 100644 --- a/src/main/perl/lib/Compress/Raw/Bzip2.pm +++ b/src/main/perl/lib/Compress/Raw/Bzip2.pm @@ -10,7 +10,7 @@ use Carp (); our ($VERSION, $XS_VERSION, @ISA, @EXPORT, $AUTOLOAD); -$VERSION = '2.218'; +$VERSION = '2.224'; $XS_VERSION = $VERSION; $VERSION = eval $VERSION; diff --git a/src/main/perl/lib/Compress/Raw/Zlib.pm b/src/main/perl/lib/Compress/Raw/Zlib.pm index e63d98019a..fee1f83c3b 100644 --- a/src/main/perl/lib/Compress/Raw/Zlib.pm +++ b/src/main/perl/lib/Compress/Raw/Zlib.pm @@ -10,7 +10,7 @@ use warnings ; use bytes ; our ($VERSION, $XS_VERSION, @ISA, @EXPORT, %EXPORT_TAGS, @EXPORT_OK, $AUTOLOAD, %DEFLATE_CONSTANTS, @DEFLATE_CONSTANTS); -$VERSION = '2.222'; +$VERSION = '2.224'; $XS_VERSION = $VERSION; $VERSION = eval $VERSION; diff --git a/src/test/resources/unit/compress_raw_bzip2.t b/src/test/resources/unit/compress_raw_bzip2.t index 779a49a757..63519b338c 100644 --- a/src/test/resources/unit/compress_raw_bzip2.t +++ b/src/test/resources/unit/compress_raw_bzip2.t @@ -8,7 +8,13 @@ eval { Compress::Raw::Bzip2->import; 1; } or plan skip_all => 'Compress::Raw::Bzip2 required'; -plan tests => 14; +plan tests => 16; + +SKIP: { + skip 'PerlOnJava provider version contract', 2 unless $^X =~ /jperl/; + is($Compress::Raw::Bzip2::VERSION, '2.224', 'advertises the audited 2.224 Perl API'); + is($Compress::Raw::Bzip2::XS_VERSION, '2.224', 'Java XS provider satisfies the 2.224 prerequisite'); +} is(Compress::Raw::Bzip2::BZ_OK(), 0, 'BZ_OK exported'); is(Compress::Raw::Bzip2::BZ_RUN_OK(), 1, 'BZ_RUN_OK exported'); diff --git a/src/test/resources/unit/compress_raw_zlib_gzip_wrapper.t b/src/test/resources/unit/compress_raw_zlib_gzip_wrapper.t index 34312cbaa5..568e871b9f 100644 --- a/src/test/resources/unit/compress_raw_zlib_gzip_wrapper.t +++ b/src/test/resources/unit/compress_raw_zlib_gzip_wrapper.t @@ -5,6 +5,12 @@ use warnings; use Test::More; use Compress::Raw::Zlib qw(WANT_GZIP Z_STREAM_END); +SKIP: { + skip 'PerlOnJava provider version contract', 2 unless $^X =~ /jperl/; + is($Compress::Raw::Zlib::VERSION, '2.224', 'advertises the audited 2.224 Perl API'); + is($Compress::Raw::Zlib::XS_VERSION, '2.224', 'Java XS provider satisfies the 2.224 prerequisite'); +} + my $payload = "gzip wrapper payload\n"; my $deflater = Compress::Raw::Zlib::Deflate->new( -WindowBits => WANT_GZIP(), diff --git a/src/test/resources/unit/current_perl_shebang_exec.t b/src/test/resources/unit/current_perl_shebang_exec.t new file mode 100644 index 0000000000..0b4213677f --- /dev/null +++ b/src/test/resources/unit/current_perl_shebang_exec.t @@ -0,0 +1,24 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More tests => 3; +use File::Spec; +use File::Temp qw(tempdir); + +SKIP: { + skip 'direct executable shebang test is Unix-specific', 3 + if $^O eq 'MSWin32'; + + my $dir = tempdir(CLEANUP => 1); + my $script = File::Spec->catfile($dir, 'current-perl-script'); + open my $fh, '>', $script or die "open $script: $!"; + print {$fh} "#!$^X\n"; + print {$fh} 'print "current-perl-ok @ARGV\\n";', "\n"; + close $fh or die "close $script: $!"; + chmod 0755, $script or die "chmod $script: $!"; + + ok(-x $script, 'generated $^X shebang script is executable'); + my $output = `$script alpha beta 2>&1`; + is($?, 0, 'generated $^X shebang script runs directly'); + is($output, "current-perl-ok alpha beta\n", 'generated script receives argv'); +}