Skip to content

Commit 32568d1

Browse files
ayagmarmaxandersenclaude
authored
fix(run): honor manifest launcher flags (#2439)
* fix(run): honor manifest launcher flags * fix(run): remove module guard and match java -jar native-access errors * Pass through Enable-Native-Access and add comprehensive tests Changes: - Replace validation with pass-through for Enable-Native-Access - Let JVM validate values instead of JBang - Consistent with how we handle Add-Opens/Add-Exports - Simpler code, future-proof if spec changes - Add addManifestOptions() helper - Similar to addAllUnnamedManifestOptions but without =ALL-UNNAMED suffix - Used for Enable-Native-Access which passes values directly - Update tests: - Replace testRejectsInvalidEnableNativeAccess with testPassesThroughEnableNativeAccessModuleName - Add testReadingAddExportsWithHelper (using new createJar() helper) - Add edge case tests: * testEmptyManifestAttributeIgnored (whitespace-only values) * testMultipleSpacesBetweenValues (multiple spaces between values) * testMultipleModulesForEnableNativeAccess (space-separated module list) - Add documentation: - New "JAR Manifest Attributes" section in running.adoc - Documents Add-Opens, Add-Exports, Enable-Native-Access - Explains how values are passed to JVM - Links to JAR spec and JEP 472 - References issue #2441 for --ignore-manifest flag Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Max Rydahl Andersen <max@xam.dk> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent fb47430 commit 32568d1

File tree

5 files changed

+221
-25
lines changed

5 files changed

+221
-25
lines changed

docs/modules/ROOT/pages/running.adoc

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,39 @@ If an item has no equals sign and no value than the value is taken to be the str
184184
----
185185

186186

187+
== JAR Manifest Attributes
188+
189+
When running JAR files, JBang honors certain manifest attributes and converts them to equivalent JVM flags.
190+
This allows JAR files to declare their requirements for module access and native functionality.
191+
192+
=== Java Module System Attributes (Java 9+)
193+
194+
JBang reads `Add-Opens` and `Add-Exports` from the JAR manifest and passes them to the JVM with `=ALL-UNNAMED` appended:
195+
196+
* `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`
197+
* `Add-Exports: jdk.compiler/com.sun.tools.javac.api` → `--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED`
198+
199+
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.
200+
201+
=== Native Access Attribute (Java 22+)
202+
203+
`Enable-Native-Access` is passed through directly to the JVM:
204+
205+
* `Enable-Native-Access: ALL-UNNAMED` → `--enable-native-access=ALL-UNNAMED`
206+
* `Enable-Native-Access: com.example.module` → `--enable-native-access=com.example.module`
207+
208+
This attribute allows code to use restricted methods from the Foreign Function & Memory API (https://openjdk.org/jeps/472[JEP 472]).
209+
210+
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.
211+
212+
=== Other Manifest Attributes
213+
214+
JBang also reads and honors:
215+
216+
* `Main-Class` - The main class to execute
217+
* `Build-Jdk` - Minimum Java version required (JBang will use this version or higher)
218+
* `Premain-Class`, `Agent-Class` - For Java agents
219+
187220
=== (Experimental) Application Class Data Sharing
188221

189222
If your scripts uses a lot of classes Class Data Sharing might help on your startup. The following requires Java 13+.

src/main/java/dev/jbang/source/Project.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ public class Project {
5454

5555
public static final String ATTR_PREMAIN_CLASS = "Premain-Class";
5656
public static final String ATTR_AGENT_CLASS = "Agent-Class";
57+
public static final String ATTR_ADD_EXPORTS = "Add-Exports";
58+
public static final String ATTR_ADD_OPENS = "Add-Opens";
59+
public static final String ATTR_ENABLE_NATIVE_ACCESS = "Enable-Native-Access";
5760

5861
public enum BuildFile {
5962
jbang("build.jbang");

src/main/java/dev/jbang/source/ProjectBuilder.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -440,18 +440,13 @@ private Project importJarMetadata(Project prj, boolean importModuleName) {
440440
prj.setJavaVersion(JavaUtil.parseJavaVersion(ver) + "+");
441441
}
442442

443-
// we pass exports/opens into the project...
443+
// we pass exports/opens/native access into the project...
444444
// TODO: this does mean we can't separate from user specified options and jar
445-
// origined ones, but not sure if needed?
445+
// originated ones, but not sure if needed?
446446
// https://openjdk.org/jeps/261#Breaking-encapsulation
447-
String exports = attrs.getValue("Add-Exports");
448-
if (exports != null) {
449-
prj.getManifestAttributes().put("Add-Exports", exports);
450-
}
451-
String opens = attrs.getValue("Add-Opens");
452-
if (opens != null) {
453-
prj.getManifestAttributes().put("Add-Opens", exports);
454-
}
447+
copyManifestAttribute(attrs, prj, Project.ATTR_ADD_EXPORTS);
448+
copyManifestAttribute(attrs, prj, Project.ATTR_ADD_OPENS);
449+
copyManifestAttribute(attrs, prj, Project.ATTR_ENABLE_NATIVE_ACCESS);
455450

456451
}
457452

@@ -482,6 +477,13 @@ private Project importJarMetadata(Project prj, boolean importModuleName) {
482477
return prj;
483478
}
484479

480+
private static void copyManifestAttribute(Attributes attrs, Project prj, String name) {
481+
String value = attrs.getValue(name);
482+
if (value != null) {
483+
prj.getManifestAttributes().put(name, value);
484+
}
485+
}
486+
485487
private Project updateProject(Project prj) {
486488
SourceSet ss = prj.getMainSourceSet();
487489
prj.addRepositories(allToMavenRepo(replaceAllProps(additionalRepos)));

src/main/java/dev/jbang/source/generators/JarCmdGenerator.java

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,27 +90,25 @@ protected List<String> generateCommandLineList() throws IOException {
9090
List<String> fullArgs = new ArrayList<>();
9191

9292
Project project = ctx.getProject();
93+
boolean runAsModule = moduleName != null && project.getModuleName().isPresent();
9394
String classpath = ctx.resolveClassPath().getClassPath();
9495

9596
List<String> optionalArgs = new ArrayList<>();
9697

9798
Jdk jdk = project.projectJdk();
9899
String javacmd = JavaUtil.resolveInJavaHome("java", jdk);
99100

100-
if (jdk.majorVersion() > 9) {
101-
String opens = ctx.getProject().getManifestAttributes().get("Add-Opens");
102-
if (opens != null) {
103-
for (String val : opens.split(" ")) {
104-
optionalArgs.add("--add-opens=" + val + "=ALL-UNNAMED");
105-
}
106-
}
101+
if (jdk.majorVersion() >= 9) {
102+
addAllUnnamedManifestOptions(optionalArgs, project.getManifestAttributes().get(Project.ATTR_ADD_OPENS),
103+
"--add-opens=");
104+
addAllUnnamedManifestOptions(optionalArgs, project.getManifestAttributes().get(Project.ATTR_ADD_EXPORTS),
105+
"--add-exports=");
106+
}
107107

108-
String exports = ctx.getProject().getManifestAttributes().get("Add-Exports");
109-
if (exports != null) {
110-
for (String val : exports.split(" ")) {
111-
optionalArgs.add("--add-exports=" + val + "=ALL-UNNAMED");
112-
}
113-
}
108+
if (jdk.majorVersion() >= 22) {
109+
addManifestOptions(optionalArgs,
110+
project.getManifestAttributes().get(Project.ATTR_ENABLE_NATIVE_ACCESS),
111+
"--enable-native-access=");
114112
}
115113

116114
addPropertyFlags(project.getProperties(), "-D", optionalArgs);
@@ -197,7 +195,7 @@ protected List<String> generateCommandLineList() throws IOException {
197195
}
198196
}
199197
if (!Util.isBlankString(classpath)) {
200-
if (moduleName != null && project.getModuleName().isPresent()) {
198+
if (runAsModule) {
201199
optionalArgs.addAll(Arrays.asList("-p", classpath));
202200
} else {
203201
optionalArgs.addAll(Arrays.asList("-classpath", classpath));
@@ -230,7 +228,7 @@ protected List<String> generateCommandLineList() throws IOException {
230228

231229
String main = Optional.ofNullable(mainClass).orElse(project.getMainClass());
232230
if (main != null && !Glob.isGlob(main)) {
233-
if (moduleName != null && project.getModuleName().isPresent()) {
231+
if (runAsModule) {
234232
String modName = moduleName.isEmpty() ? ModuleUtil.getModuleName(project) : moduleName;
235233
fullArgs.add("-m");
236234
fullArgs.add(modName + "/" + main);
@@ -312,6 +310,24 @@ protected String generateCommandLineString(List<String> fullArgs) throws IOExcep
312310
.asCommandLine();
313311
}
314312

313+
private static void addAllUnnamedManifestOptions(List<String> result, String manifestValue, String optionPrefix) {
314+
if (manifestValue == null) {
315+
return;
316+
}
317+
Arrays.stream(manifestValue.trim().split("\\s+"))
318+
.filter(val -> !val.isEmpty())
319+
.forEach(val -> result.add(optionPrefix + val + "=ALL-UNNAMED"));
320+
}
321+
322+
private static void addManifestOptions(List<String> result, String manifestValue, String optionPrefix) {
323+
if (manifestValue == null) {
324+
return;
325+
}
326+
Arrays.stream(manifestValue.trim().split("\\s+"))
327+
.filter(val -> !val.isEmpty())
328+
.forEach(val -> result.add(optionPrefix + val));
329+
}
330+
315331
private static void addPropertyFlags(Map<String, String> properties, String def, List<String> result) {
316332
properties.forEach((k, e) -> result.add(def + k + "=" + e));
317333
}

src/test/java/dev/jbang/cli/TestRun.java

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import static org.junit.jupiter.api.Assertions.assertEquals;
1616
import static org.junit.jupiter.api.Assertions.assertThrows;
1717
import static org.junit.jupiter.api.Assertions.fail;
18+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
1819

1920
import java.io.ByteArrayInputStream;
2021
import java.io.ByteArrayOutputStream;
@@ -36,6 +37,8 @@
3637
import java.util.Map;
3738
import java.util.jar.Attributes;
3839
import java.util.jar.JarFile;
40+
import java.util.jar.JarOutputStream;
41+
import java.util.jar.Manifest;
3942
import java.util.regex.Pattern;
4043
import java.util.stream.Collectors;
4144

@@ -2604,6 +2607,131 @@ void testReadingAddExports() throws IOException {
26042607
"--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED"));
26052608
}
26062609

2610+
@Test
2611+
void testReadingAddOpens(@TempDir Path output) throws IOException {
2612+
assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+");
2613+
String opens = "java.base/java.lang java.base/java.nio";
2614+
Path jar = createJar(output, Integer.toString(Runtime.version().feature()),
2615+
Collections.singletonMap(Project.ATTR_ADD_OPENS, opens));
2616+
2617+
CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString());
2618+
Run run = (Run) pr.subcommand().commandSpec().userObject();
2619+
2620+
ProjectBuilder pb = run.createProjectBuilderForRun();
2621+
Project code = pb.build(jar.toString());
2622+
String cmd = CmdGenerator.builder(code).build().generate();
2623+
2624+
assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ADD_OPENS, opens));
2625+
assertThat(cmd, containsString("--add-opens=java.base/java.lang=ALL-UNNAMED"));
2626+
assertThat(cmd, containsString("--add-opens=java.base/java.nio=ALL-UNNAMED"));
2627+
}
2628+
2629+
@Test
2630+
void testReadingEnableNativeAccess(@TempDir Path output) throws IOException {
2631+
assumeTrue(Runtime.version().feature() >= 22, "requires Java 22+");
2632+
Path jar = createJar(output, Integer.toString(Runtime.version().feature()),
2633+
Collections.singletonMap(Project.ATTR_ENABLE_NATIVE_ACCESS, "ALL-UNNAMED"));
2634+
2635+
CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString());
2636+
Run run = (Run) pr.subcommand().commandSpec().userObject();
2637+
2638+
ProjectBuilder pb = run.createProjectBuilderForRun();
2639+
Project code = pb.build(jar.toString());
2640+
2641+
assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ENABLE_NATIVE_ACCESS, "ALL-UNNAMED"));
2642+
assertThat(CmdGenerator.builder(code).build().generate(),
2643+
containsString("--enable-native-access=ALL-UNNAMED"));
2644+
}
2645+
2646+
@Test
2647+
void testPassesThroughEnableNativeAccessModuleName(@TempDir Path output) throws IOException {
2648+
assumeTrue(Runtime.version().feature() >= 22, "requires Java 22+");
2649+
Path jar = createJar(output, Integer.toString(Runtime.version().feature()),
2650+
Collections.singletonMap(Project.ATTR_ENABLE_NATIVE_ACCESS, "com.example.module"));
2651+
2652+
CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString());
2653+
Run run = (Run) pr.subcommand().commandSpec().userObject();
2654+
2655+
ProjectBuilder pb = run.createProjectBuilderForRun();
2656+
Project code = pb.build(jar.toString());
2657+
String cmd = CmdGenerator.builder(code).build().generate();
2658+
2659+
assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ENABLE_NATIVE_ACCESS, "com.example.module"));
2660+
assertThat(cmd, containsString("--enable-native-access=com.example.module"));
2661+
}
2662+
2663+
@Test
2664+
void testReadingAddExportsWithHelper(@TempDir Path output) throws IOException {
2665+
assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+");
2666+
String exports = "jdk.compiler/com.sun.tools.javac.api jdk.compiler/com.sun.tools.javac.tree";
2667+
Path jar = createJar(output, Integer.toString(Runtime.version().feature()),
2668+
Collections.singletonMap(Project.ATTR_ADD_EXPORTS, exports));
2669+
2670+
CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString());
2671+
Run run = (Run) pr.subcommand().commandSpec().userObject();
2672+
2673+
ProjectBuilder pb = run.createProjectBuilderForRun();
2674+
Project code = pb.build(jar.toString());
2675+
String cmd = CmdGenerator.builder(code).build().generate();
2676+
2677+
assertThat(code.getManifestAttributes(), hasEntry(Project.ATTR_ADD_EXPORTS, exports));
2678+
assertThat(cmd, containsString("--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED"));
2679+
assertThat(cmd, containsString("--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED"));
2680+
}
2681+
2682+
@Test
2683+
void testEmptyManifestAttributeIgnored(@TempDir Path output) throws IOException {
2684+
assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+");
2685+
Path jar = createJar(output, Integer.toString(Runtime.version().feature()),
2686+
Collections.singletonMap(Project.ATTR_ADD_OPENS, " "));
2687+
2688+
CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString());
2689+
Run run = (Run) pr.subcommand().commandSpec().userObject();
2690+
2691+
ProjectBuilder pb = run.createProjectBuilderForRun();
2692+
Project code = pb.build(jar.toString());
2693+
String cmd = CmdGenerator.builder(code).build().generate();
2694+
2695+
// Should not add any --add-opens flags for empty/whitespace-only values
2696+
assertThat(cmd, not(containsString("--add-opens=")));
2697+
}
2698+
2699+
@Test
2700+
void testMultipleSpacesBetweenValues(@TempDir Path output) throws IOException {
2701+
assumeTrue(Runtime.version().feature() >= 9, "requires Java 9+");
2702+
String opens = "java.base/java.lang java.base/java.nio";
2703+
Path jar = createJar(output, Integer.toString(Runtime.version().feature()),
2704+
Collections.singletonMap(Project.ATTR_ADD_OPENS, opens));
2705+
2706+
CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString());
2707+
Run run = (Run) pr.subcommand().commandSpec().userObject();
2708+
2709+
ProjectBuilder pb = run.createProjectBuilderForRun();
2710+
Project code = pb.build(jar.toString());
2711+
String cmd = CmdGenerator.builder(code).build().generate();
2712+
2713+
// Should handle multiple spaces correctly
2714+
assertThat(cmd, containsString("--add-opens=java.base/java.lang=ALL-UNNAMED"));
2715+
assertThat(cmd, containsString("--add-opens=java.base/java.nio=ALL-UNNAMED"));
2716+
}
2717+
2718+
@Test
2719+
void testMultipleModulesForEnableNativeAccess(@TempDir Path output) throws IOException {
2720+
assumeTrue(Runtime.version().feature() >= 22, "requires Java 22+");
2721+
Path jar = createJar(output, Integer.toString(Runtime.version().feature()),
2722+
Collections.singletonMap(Project.ATTR_ENABLE_NATIVE_ACCESS, "module1 module2"));
2723+
2724+
CommandLine.ParseResult pr = JBang.getCommandLine().parseArgs("run", jar.toString());
2725+
Run run = (Run) pr.subcommand().commandSpec().userObject();
2726+
2727+
ProjectBuilder pb = run.createProjectBuilderForRun();
2728+
Project code = pb.build(jar.toString());
2729+
String cmd = CmdGenerator.builder(code).build().generate();
2730+
2731+
assertThat(cmd, containsString("--enable-native-access=module1"));
2732+
assertThat(cmd, containsString("--enable-native-access=module2"));
2733+
}
2734+
26072735
@Test
26082736
@Disabled("java 8 is not installing reliably on github action")
26092737
void testReadingNoAddExportsOnJava8() throws IOException {
@@ -2692,4 +2820,18 @@ void testRunLocalWarFile() throws IOException {
26922820
Files.deleteIfExists(warPath);
26932821
}
26942822
}
2823+
2824+
private Path createJar(Path outputDir, String buildJdk, Map<String, String> manifestAttributes) throws IOException {
2825+
Path jar = outputDir.resolve("manifest-test.jar");
2826+
Manifest manifest = new Manifest();
2827+
Attributes attrs = manifest.getMainAttributes();
2828+
attrs.put(Attributes.Name.MANIFEST_VERSION, "1.0");
2829+
attrs.put(Attributes.Name.MAIN_CLASS, "test.Main");
2830+
attrs.putValue(JarBuildStep.ATTR_BUILD_JDK, buildJdk);
2831+
manifestAttributes.forEach(attrs::putValue);
2832+
try (JarOutputStream ignored = new JarOutputStream(Files.newOutputStream(jar), manifest)) {
2833+
// Manifest-only JAR is enough for command generation tests.
2834+
}
2835+
return jar;
2836+
}
26952837
}

0 commit comments

Comments
 (0)