diff --git a/docs/modules/ROOT/pages/running.adoc b/docs/modules/ROOT/pages/running.adoc index 3ec56b141..965831e85 100644 --- a/docs/modules/ROOT/pages/running.adoc +++ b/docs/modules/ROOT/pages/running.adoc @@ -184,6 +184,39 @@ If an item has no equals sign and no value than the value is taken to be the str ---- +== JAR Manifest Attributes + +When running JAR files, JBang honors certain manifest attributes and converts them to equivalent JVM flags. +This allows JAR files to declare their requirements for module access and native functionality. + +=== Java Module System Attributes (Java 9+) + +JBang reads `Add-Opens` and `Add-Exports` from the JAR manifest and passes them to the JVM with `=ALL-UNNAMED` appended: + +* `Add-Opens: java.base/java.lang java.base/java.nio` → `--add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED` +* `Add-Exports: jdk.compiler/com.sun.tools.javac.api` → `--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED` + +These attributes are specified in the https://docs.oracle.com/en/java/javase/25/docs/specs/jar/jar.html[JAR File Specification] and allow JARs to declare which module internals they need access to. + +=== Native Access Attribute (Java 22+) + +`Enable-Native-Access` is passed through directly to the JVM: + +* `Enable-Native-Access: ALL-UNNAMED` → `--enable-native-access=ALL-UNNAMED` +* `Enable-Native-Access: com.example.module` → `--enable-native-access=com.example.module` + +This attribute allows code to use restricted methods from the Foreign Function & Memory API (https://openjdk.org/jeps/472[JEP 472]). + +NOTE: For executable JARs run with `java -jar`, the JAR specification restricts `Enable-Native-Access` to only `ALL-UNNAMED` in Java 22 and later. The JVM will reject other values. JBang passes through whatever value is in the manifest, allowing the JVM to perform validation. + +=== Other Manifest Attributes + +JBang also reads and honors: + +* `Main-Class` - The main class to execute +* `Build-Jdk` - Minimum Java version required (JBang will use this version or higher) +* `Premain-Class`, `Agent-Class` - For Java agents + === (Experimental) Application Class Data Sharing If your scripts uses a lot of classes Class Data Sharing might help on your startup. The following requires Java 13+. diff --git a/src/main/java/dev/jbang/source/Project.java b/src/main/java/dev/jbang/source/Project.java index 5d583fbdd..035f21f30 100644 --- a/src/main/java/dev/jbang/source/Project.java +++ b/src/main/java/dev/jbang/source/Project.java @@ -54,6 +54,9 @@ public class Project { public static final String ATTR_PREMAIN_CLASS = "Premain-Class"; public static final String ATTR_AGENT_CLASS = "Agent-Class"; + public static final String ATTR_ADD_EXPORTS = "Add-Exports"; + public static final String ATTR_ADD_OPENS = "Add-Opens"; + public static final String ATTR_ENABLE_NATIVE_ACCESS = "Enable-Native-Access"; public enum BuildFile { jbang("build.jbang"); diff --git a/src/main/java/dev/jbang/source/ProjectBuilder.java b/src/main/java/dev/jbang/source/ProjectBuilder.java index 17bb60b14..76d0fa15b 100644 --- a/src/main/java/dev/jbang/source/ProjectBuilder.java +++ b/src/main/java/dev/jbang/source/ProjectBuilder.java @@ -440,18 +440,13 @@ private Project importJarMetadata(Project prj, boolean importModuleName) { prj.setJavaVersion(JavaUtil.parseJavaVersion(ver) + "+"); } - // we pass exports/opens into the project... + // we pass exports/opens/native access into the project... // TODO: this does mean we can't separate from user specified options and jar - // origined ones, but not sure if needed? + // originated ones, but not sure if needed? // https://openjdk.org/jeps/261#Breaking-encapsulation - String exports = attrs.getValue("Add-Exports"); - if (exports != null) { - prj.getManifestAttributes().put("Add-Exports", exports); - } - String opens = attrs.getValue("Add-Opens"); - if (opens != null) { - prj.getManifestAttributes().put("Add-Opens", exports); - } + copyManifestAttribute(attrs, prj, Project.ATTR_ADD_EXPORTS); + copyManifestAttribute(attrs, prj, Project.ATTR_ADD_OPENS); + copyManifestAttribute(attrs, prj, Project.ATTR_ENABLE_NATIVE_ACCESS); } @@ -482,6 +477,13 @@ private Project importJarMetadata(Project prj, boolean importModuleName) { return prj; } + private static void copyManifestAttribute(Attributes attrs, Project prj, String name) { + String value = attrs.getValue(name); + if (value != null) { + prj.getManifestAttributes().put(name, value); + } + } + private Project updateProject(Project prj) { SourceSet ss = prj.getMainSourceSet(); prj.addRepositories(allToMavenRepo(replaceAllProps(additionalRepos))); diff --git a/src/main/java/dev/jbang/source/generators/JarCmdGenerator.java b/src/main/java/dev/jbang/source/generators/JarCmdGenerator.java index 576cff8bf..cc5473106 100644 --- a/src/main/java/dev/jbang/source/generators/JarCmdGenerator.java +++ b/src/main/java/dev/jbang/source/generators/JarCmdGenerator.java @@ -90,6 +90,7 @@ protected List generateCommandLineList() throws IOException { List fullArgs = new ArrayList<>(); Project project = ctx.getProject(); + boolean runAsModule = moduleName != null && project.getModuleName().isPresent(); String classpath = ctx.resolveClassPath().getClassPath(); List optionalArgs = new ArrayList<>(); @@ -97,20 +98,17 @@ protected List generateCommandLineList() throws IOException { Jdk jdk = project.projectJdk(); String javacmd = JavaUtil.resolveInJavaHome("java", jdk); - if (jdk.majorVersion() > 9) { - String opens = ctx.getProject().getManifestAttributes().get("Add-Opens"); - if (opens != null) { - for (String val : opens.split(" ")) { - optionalArgs.add("--add-opens=" + val + "=ALL-UNNAMED"); - } - } + if (jdk.majorVersion() >= 9) { + addAllUnnamedManifestOptions(optionalArgs, project.getManifestAttributes().get(Project.ATTR_ADD_OPENS), + "--add-opens="); + addAllUnnamedManifestOptions(optionalArgs, project.getManifestAttributes().get(Project.ATTR_ADD_EXPORTS), + "--add-exports="); + } - String exports = ctx.getProject().getManifestAttributes().get("Add-Exports"); - if (exports != null) { - for (String val : exports.split(" ")) { - optionalArgs.add("--add-exports=" + val + "=ALL-UNNAMED"); - } - } + if (jdk.majorVersion() >= 22) { + addManifestOptions(optionalArgs, + project.getManifestAttributes().get(Project.ATTR_ENABLE_NATIVE_ACCESS), + "--enable-native-access="); } addPropertyFlags(project.getProperties(), "-D", optionalArgs); @@ -197,7 +195,7 @@ protected List generateCommandLineList() throws IOException { } } if (!Util.isBlankString(classpath)) { - if (moduleName != null && project.getModuleName().isPresent()) { + if (runAsModule) { optionalArgs.addAll(Arrays.asList("-p", classpath)); } else { optionalArgs.addAll(Arrays.asList("-classpath", classpath)); @@ -230,7 +228,7 @@ protected List generateCommandLineList() throws IOException { String main = Optional.ofNullable(mainClass).orElse(project.getMainClass()); if (main != null && !Glob.isGlob(main)) { - if (moduleName != null && project.getModuleName().isPresent()) { + if (runAsModule) { String modName = moduleName.isEmpty() ? ModuleUtil.getModuleName(project) : moduleName; fullArgs.add("-m"); fullArgs.add(modName + "/" + main); @@ -312,6 +310,24 @@ protected String generateCommandLineString(List fullArgs) throws IOExcep .asCommandLine(); } + private static void addAllUnnamedManifestOptions(List result, String manifestValue, String optionPrefix) { + if (manifestValue == null) { + return; + } + Arrays.stream(manifestValue.trim().split("\\s+")) + .filter(val -> !val.isEmpty()) + .forEach(val -> result.add(optionPrefix + val + "=ALL-UNNAMED")); + } + + private static void addManifestOptions(List result, String manifestValue, String optionPrefix) { + if (manifestValue == null) { + return; + } + Arrays.stream(manifestValue.trim().split("\\s+")) + .filter(val -> !val.isEmpty()) + .forEach(val -> result.add(optionPrefix + val)); + } + private static void addPropertyFlags(Map properties, String def, List result) { properties.forEach((k, e) -> result.add(def + k + "=" + e)); } diff --git a/src/test/java/dev/jbang/cli/TestRun.java b/src/test/java/dev/jbang/cli/TestRun.java index 83b50145a..5c5505d72 100644 --- a/src/test/java/dev/jbang/cli/TestRun.java +++ b/src/test/java/dev/jbang/cli/TestRun.java @@ -15,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -36,6 +37,8 @@ import java.util.Map; import java.util.jar.Attributes; import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -2604,6 +2607,131 @@ void testReadingAddExports() throws IOException { "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED")); } + @Test + void testReadingAddOpens(@TempDir Path output) throws IOException { + assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+"); + String opens = "java.base/java.lang java.base/java.nio"; + Path jar = createJar(output, Integer.toString(Runtime.version().feature()), + Collections.singletonMap(Project.ATTR_ADD_OPENS, opens)); + + CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString()); + Run run = (Run) pr.subcommand().commandSpec().userObject(); + + ProjectBuilder pb = run.createProjectBuilderForRun(); + Project code = pb.build(jar.toString()); + String cmd = CmdGenerator.builder(code).build().generate(); + + assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ADD_OPENS, opens)); + assertThat(cmd, containsString("--add-opens=java.base/java.lang=ALL-UNNAMED")); + assertThat(cmd, containsString("--add-opens=java.base/java.nio=ALL-UNNAMED")); + } + + @Test + void testReadingEnableNativeAccess(@TempDir Path output) throws IOException { + assumeTrue(Runtime.version().feature() >= 22, "requires Java 22+"); + Path jar = createJar(output, Integer.toString(Runtime.version().feature()), + Collections.singletonMap(Project.ATTR_ENABLE_NATIVE_ACCESS, "ALL-UNNAMED")); + + CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString()); + Run run = (Run) pr.subcommand().commandSpec().userObject(); + + ProjectBuilder pb = run.createProjectBuilderForRun(); + Project code = pb.build(jar.toString()); + + assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ENABLE_NATIVE_ACCESS, "ALL-UNNAMED")); + assertThat(CmdGenerator.builder(code).build().generate(), + containsString("--enable-native-access=ALL-UNNAMED")); + } + + @Test + void testPassesThroughEnableNativeAccessModuleName(@TempDir Path output) throws IOException { + assumeTrue(Runtime.version().feature() >= 22, "requires Java 22+"); + Path jar = createJar(output, Integer.toString(Runtime.version().feature()), + Collections.singletonMap(Project.ATTR_ENABLE_NATIVE_ACCESS, "com.example.module")); + + CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString()); + Run run = (Run) pr.subcommand().commandSpec().userObject(); + + ProjectBuilder pb = run.createProjectBuilderForRun(); + Project code = pb.build(jar.toString()); + String cmd = CmdGenerator.builder(code).build().generate(); + + assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ENABLE_NATIVE_ACCESS, "com.example.module")); + assertThat(cmd, containsString("--enable-native-access=com.example.module")); + } + + @Test + void testReadingAddExportsWithHelper(@TempDir Path output) throws IOException { + assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+"); + String exports = "jdk.compiler/com.sun.tools.javac.api jdk.compiler/com.sun.tools.javac.tree"; + Path jar = createJar(output, Integer.toString(Runtime.version().feature()), + Collections.singletonMap(Project.ATTR_ADD_EXPORTS, exports)); + + CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString()); + Run run = (Run) pr.subcommand().commandSpec().userObject(); + + ProjectBuilder pb = run.createProjectBuilderForRun(); + Project code = pb.build(jar.toString()); + String cmd = CmdGenerator.builder(code).build().generate(); + + assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ADD_EXPORTS, exports)); + assertThat(cmd, containsString("--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED")); + assertThat(cmd, containsString("--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED")); + } + + @Test + void testEmptyManifestAttributeIgnored(@TempDir Path output) throws IOException { + assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+"); + Path jar = createJar(output, Integer.toString(Runtime.version().feature()), + Collections.singletonMap(Project.ATTR_ADD_OPENS, " ")); + + CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString()); + Run run = (Run) pr.subcommand().commandSpec().userObject(); + + ProjectBuilder pb = run.createProjectBuilderForRun(); + Project code = pb.build(jar.toString()); + String cmd = CmdGenerator.builder(code).build().generate(); + + // Should not add any --add-opens flags for empty/whitespace-only values + assertThat(cmd, not(containsString("--add-opens="))); + } + + @Test + void testMultipleSpacesBetweenValues(@TempDir Path output) throws IOException { + assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+"); + String opens = "java.base/java.lang java.base/java.nio"; + Path jar = createJar(output, Integer.toString(Runtime.version().feature()), + Collections.singletonMap(Project.ATTR_ADD_OPENS, opens)); + + CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString()); + Run run = (Run) pr.subcommand().commandSpec().userObject(); + + ProjectBuilder pb = run.createProjectBuilderForRun(); + Project code = pb.build(jar.toString()); + String cmd = CmdGenerator.builder(code).build().generate(); + + // Should handle multiple spaces correctly + assertThat(cmd, containsString("--add-opens=java.base/java.lang=ALL-UNNAMED")); + assertThat(cmd, containsString("--add-opens=java.base/java.nio=ALL-UNNAMED")); + } + + @Test + void testMultipleModulesForEnableNativeAccess(@TempDir Path output) throws IOException { + assumeTrue(Runtime.version().feature() >= 22, "requires Java 22+"); + Path jar = createJar(output, Integer.toString(Runtime.version().feature()), + Collections.singletonMap(Project.ATTR_ENABLE_NATIVE_ACCESS, "module1 module2")); + + CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString()); + Run run = (Run) pr.subcommand().commandSpec().userObject(); + + ProjectBuilder pb = run.createProjectBuilderForRun(); + Project code = pb.build(jar.toString()); + String cmd = CmdGenerator.builder(code).build().generate(); + + assertThat(cmd, containsString("--enable-native-access=module1")); + assertThat(cmd, containsString("--enable-native-access=module2")); + } + @Test @Disabled("java 8 is not installing reliably on github action") void testReadingNoAddExportsOnJava8() throws IOException { @@ -2692,4 +2820,18 @@ void testRunLocalWarFile() throws IOException { Files.deleteIfExists(warPath); } } + + private Path createJar(Path outputDir, String buildJdk, Map manifestAttributes) throws IOException { + Path jar = outputDir.resolve("manifest-test.jar"); + Manifest manifest = new Manifest(); + Attributes attrs = manifest.getMainAttributes(); + attrs.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + attrs.put(Attributes.Name.MAIN_CLASS, "test.Main"); + attrs.putValue(JarBuildStep.ATTR_BUILD_JDK, buildJdk); + manifestAttributes.forEach(attrs::putValue); + try (JarOutputStream ignored = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + // Manifest-only JAR is enough for command generation tests. + } + return jar; + } }