Skip to content
Merged
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
33 changes: 33 additions & 0 deletions docs/modules/ROOT/pages/running.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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+.
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/dev/jbang/source/Project.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
22 changes: 12 additions & 10 deletions src/main/java/dev/jbang/source/ProjectBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

}

Expand Down Expand Up @@ -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)));
Expand Down
46 changes: 31 additions & 15 deletions src/main/java/dev/jbang/source/generators/JarCmdGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -90,27 +90,25 @@ protected List<String> generateCommandLineList() throws IOException {
List<String> fullArgs = new ArrayList<>();

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

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

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);
Expand Down Expand Up @@ -197,7 +195,7 @@ protected List<String> 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));
Expand Down Expand Up @@ -230,7 +228,7 @@ protected List<String> 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);
Expand Down Expand Up @@ -312,6 +310,24 @@ protected String generateCommandLineString(List<String> fullArgs) throws IOExcep
.asCommandLine();
}

private static void addAllUnnamedManifestOptions(List<String> 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<String> 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<String, String> properties, String def, List<String> result) {
properties.forEach((k, e) -> result.add(def + k + "=" + e));
}
Expand Down
142 changes: 142 additions & 0 deletions src/test/java/dev/jbang/cli/TestRun.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2692,4 +2820,18 @@ void testRunLocalWarFile() throws IOException {
Files.deleteIfExists(warPath);
}
}

private Path createJar(Path outputDir, String buildJdk, Map<String, String> 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;
}
}