diff --git a/docs/reference-manual/native-image/ReachabilityMetadata.md b/docs/reference-manual/native-image/ReachabilityMetadata.md index d3aa738e3671..b15886ccaca2 100644 --- a/docs/reference-manual/native-image/ReachabilityMetadata.md +++ b/docs/reference-manual/native-image/ReachabilityMetadata.md @@ -738,6 +738,8 @@ To request a bundle from a specific module: } ``` +Use `"module": "ALL-UNNAMED"` to restrict bundle lookup to the class path instead of allowing the same bundle name to resolve from named modules. + Resource bundles are included for all locales that are [included into the image](#locales). ### Locales diff --git a/docs/reference-manual/native-image/assets/resource-config-schema-v1.1.0.json b/docs/reference-manual/native-image/assets/resource-config-schema-v1.1.0.json index 2dc5535dd780..95f6b846f502 100644 --- a/docs/reference-manual/native-image/assets/resource-config-schema-v1.1.0.json +++ b/docs/reference-manual/native-image/assets/resource-config-schema-v1.1.0.json @@ -120,6 +120,10 @@ "additionalProperties": false, "type": "object" }, + "module": { + "type": "string", + "title": "Module containing the resource bundle" + }, "name": { "type": "string", "title": "Fully qualified name of the resource bundle" @@ -152,4 +156,4 @@ "type": "object", "title": "JSON schema for the resource-config that GraalVM Native Image uses", "description": "Native Image will iterate over all resources and match their relative paths against the Java Regex specified in . If the path matches the Regex, the resource is included. The statement instructs Native Image to omit certain included resources that match the given " -} \ No newline at end of file +} diff --git a/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/hosted/RuntimeResourceAccess.java b/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/hosted/RuntimeResourceAccess.java index 17d60b532660..ccf3ce1b0c07 100644 --- a/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/hosted/RuntimeResourceAccess.java +++ b/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/hosted/RuntimeResourceAccess.java @@ -60,6 +60,7 @@ public final class RuntimeResourceAccess { private static final APIDeprecationSupport deprecationFlag = ImageSingletons.lookup(APIDeprecationSupport.class); + private static final String ALL_UNNAMED_MODULE = "ALL-UNNAMED"; /** * Make Java resource {@code resourcePath} from {@code module} available at run time. If the @@ -106,9 +107,10 @@ public static void addResource(Module module, String resourcePath, byte[] resour */ public static void addResourceBundle(Module module, String baseBundleName, Locale[] locales) { deprecationFlag.printDeprecationWarning(); + Objects.requireNonNull(baseBundleName); Objects.requireNonNull(locales); RuntimeResourceSupport.singleton().addResourceBundles(AccessCondition.unconditional(), - withModuleName(module, baseBundleName), Arrays.asList(locales)); + bundleModuleName(module), baseBundleName, Arrays.asList(locales)); } /** @@ -122,14 +124,14 @@ public static void addResourceBundle(Module module, String baseBundleName, Local */ public static void addResourceBundle(Module module, String bundleName) { deprecationFlag.printDeprecationWarning(); + Objects.requireNonNull(bundleName); RuntimeResourceSupport.singleton().addResourceBundles(AccessCondition.unconditional(), - false, withModuleName(module, bundleName)); + false, bundleModuleName(module), bundleName); } - private static String withModuleName(Module module, String str) { + private static String bundleModuleName(Module module) { Objects.requireNonNull(module); - Objects.requireNonNull(str); - return module.isNamed() ? module.getName() + ":" + str : str; + return module.isNamed() ? module.getName() : ALL_UNNAMED_MODULE; } private RuntimeResourceAccess() { diff --git a/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/impl/RuntimeResourceSupport.java b/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/impl/RuntimeResourceSupport.java index 2e04fcec0730..9352731b497d 100644 --- a/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/impl/RuntimeResourceSupport.java +++ b/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/impl/RuntimeResourceSupport.java @@ -61,8 +61,16 @@ static RuntimeResourceSupport singleton() { void addResourceBundles(C condition, boolean preserved, String name); + default void addResourceBundles(C condition, boolean preserved, String moduleName, String name) { + addResourceBundles(condition, preserved, qualifyBundleName(moduleName, name)); + } + void addResourceBundles(C condition, String basename, Collection locales); + default void addResourceBundles(C condition, String moduleName, String basename, Collection locales) { + addResourceBundles(condition, qualifyBundleName(moduleName, basename), locales); + } + /* Following functions are used only from features */ void addCondition(AccessCondition condition, Module module, String resourcePath); @@ -78,4 +86,9 @@ default void addResource(AccessCondition condition, Module module, String resour } void injectResource(Module module, String resourcePath, byte[] resourceContent, Object origin); + + /* Adapter for legacy string-based bundle registration APIs. */ + private static String qualifyBundleName(String moduleName, String bundleName) { + return moduleName != null && !moduleName.isEmpty() ? moduleName + ":" + bundleName : bundleName; + } } diff --git a/substratevm/mx.substratevm/mx_substratevm.py b/substratevm/mx.substratevm/mx_substratevm.py index 2b48bb315304..62a5d2619ddd 100644 --- a/substratevm/mx.substratevm/mx_substratevm.py +++ b/substratevm/mx.substratevm/mx_substratevm.py @@ -512,6 +512,18 @@ def svm_gate_body(args, tasks): with native_image_context(IMAGE_ASSERTION_FLAGS): native_unittests_task(args.extra_image_builder_arguments) + with Task('hosted jvm unittests', tasks, tags=[GraalTags.native_unittests]) as t: + if t: + jvm_unittest(['--record-results', '--print-failed', 'failed.txt', + '--add-exports=jdk.internal.vm.ci/jdk.vm.ci.meta=ALL-UNNAMED', + '--add-exports=jdk.internal.vm.ci/jdk.vm.ci.meta.annotation=ALL-UNNAMED', + '--add-exports=jdk.internal.vm.ci/jdk.vm.ci.meta.annotation=jdk.graal.compiler.vmaccess', + '--add-exports=jdk.internal.vm.ci/jdk.vm.ci.code=ALL-UNNAMED', + '--add-exports=jdk.graal.compiler/jdk.graal.compiler.util.json=ALL-UNNAMED', + '--add-exports=org.graalvm.nativeimage/org.graalvm.nativeimage.impl=ALL-UNNAMED', + '--add-opens=org.graalvm.nativeimage/org.graalvm.nativeimage.impl=ALL-UNNAMED', + 'com.oracle.svm.hosted.jdk.localization']) + with Task('conditional configuration tests', tasks, tags=[GraalTags.condconfig]) as t: if t: with native_image_context(IMAGE_ASSERTION_FLAGS) as native_image: diff --git a/substratevm/mx.substratevm/suite.py b/substratevm/mx.substratevm/suite.py index 264524ab0740..2d8c90a43efc 100644 --- a/substratevm/mx.substratevm/suite.py +++ b/substratevm/mx.substratevm/suite.py @@ -1278,6 +1278,31 @@ "jacoco" : "exclude", }, + "com.oracle.svm.hosted.test": { + "subDir": "src", + "sourceDirs": ["src"], + "dependencies": [ + "mx:JUNIT_TOOL", + "sdk:NATIVEIMAGE", + "com.oracle.svm.hosted", + ], + "requiresConcealed": { + "jdk.internal.vm.ci": [ + "jdk.vm.ci.meta", + "jdk.vm.ci.meta.annotation", + ], + }, + "checkstyle": "com.oracle.svm.test", + "workingSets": "SVM", + "annotationProcessors": [ + "compiler:GRAAL_PROCESSOR", + "SVM_PROCESSOR", + ], + "javaCompliance" : "24+", + "testProject": True, + "jacoco" : "exclude", + }, + "com.oracle.svm.tutorial" : { "subDir": "src", "sourceDirs" : ["src"], @@ -2728,6 +2753,21 @@ "testDistribution" : True, }, + "SVM_HOSTED_TESTS" : { + "subDir": "src", + "relpath" : True, + "dependencies" : [ + "com.oracle.svm.hosted.test", + ], + "distDependencies": [ + "mx:JUNIT_TOOL", + "sdk:NATIVEIMAGE", + "SVM", + "SVM_CONFIGURE", + ], + "testDistribution" : True, + }, + # Special test distribution used for testing inclusion of resources from jar files with a space in their name. # The space in the distribution name is intentional. "SVM_TESTS WITH SPACE" : { diff --git a/substratevm/src/com.oracle.svm.configure.test/src/com/oracle/svm/configure/test/config/ResourceConfigurationTest.java b/substratevm/src/com.oracle.svm.configure.test/src/com/oracle/svm/configure/test/config/ResourceConfigurationTest.java index 26494cb5e0c1..942f9519c9c8 100644 --- a/substratevm/src/com.oracle.svm.configure.test/src/com/oracle/svm/configure/test/config/ResourceConfigurationTest.java +++ b/substratevm/src/com.oracle.svm.configure.test/src/com/oracle/svm/configure/test/config/ResourceConfigurationTest.java @@ -28,6 +28,8 @@ import java.io.IOException; import java.io.PipedReader; import java.io.PipedWriter; +import java.io.StringReader; +import java.io.StringWriter; import java.util.Collection; import java.util.EnumSet; import java.util.LinkedList; @@ -150,4 +152,176 @@ public void addClassBasedResourceBundle(UnresolvedAccessCondition condition, Str throw new RuntimeException(e); } } + + @Test + public void moduleQualifiedBundlesRoundTripInLegacyConfig() throws IOException { + String json = """ + { + "resources": { + "includes": [] + }, + "bundles": [ + { + "module": "first.module", + "name": "com.example.Messages", + "locales": ["en-US"] + }, + { + "module": "second.module", + "name": "com.example.Messages", + "classNames": ["com.example.MessagesBundle"] + } + ], + "globs": [] + } + """; + + ResourceConfiguration rc = new ResourceConfiguration(); + rc.createParser(false, EnumSet.of(ConfigurationParserOption.STRICT_CONFIGURATION)).parseAndRegister(new StringReader(json)); + + UnresolvedAccessCondition condition = UnresolvedAccessCondition.unconditional(); + Assert.assertTrue(rc.anyBundleMatches(condition, "first.module", "com.example.Messages")); + Assert.assertTrue(rc.anyBundleMatches(condition, "second.module", "com.example.Messages")); + Assert.assertFalse(rc.anyBundleMatches(condition, "com.example.Messages")); + + String printed = printLegacyJson(rc); + Assert.assertTrue(printed.contains("\"module\":\"first.module\"")); + Assert.assertTrue(printed.contains("\"module\":\"second.module\"")); + Assert.assertTrue(printed.contains("\"name\":\"com.example.Messages\"")); + Assert.assertTrue(printed.contains("\"classNames\":[\"com.example.MessagesBundle\"]")); + Assert.assertTrue(printed.contains("\"locales\":[\"en-US\"]")); + } + + @Test + public void moduleQualifiedBundlesUseQualifiedNamesForLegacyRegistryCallbacks() throws IOException { + String json = """ + { + "bundles": [ + { + "module": "first.module", + "name": "com.example.Messages", + "locales": ["en-US"] + }, + { + "module": "second.module", + "name": "com.example.Messages", + "classNames": ["com.example.MessagesBundle"] + }, + { + "module": "third.module", + "name": "com.example.Messages" + } + ] + } + """; + + List localizedBundles = new LinkedList<>(); + List classBasedBundles = new LinkedList<>(); + List allLocaleBundles = new LinkedList<>(); + + ResourcesRegistry registry = new ResourcesRegistry<>() { + @Override + public void addResources(UnresolvedAccessCondition condition, String pattern, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void addGlob(UnresolvedAccessCondition condition, String module, String glob, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void addResourceEntry(Module module, String resourcePath, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void injectResource(Module module, String resourcePath, byte[] resourceContent, Object origin) { + } + + @Override + public void ignoreResources(UnresolvedAccessCondition condition, String pattern, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void addResourceBundles(UnresolvedAccessCondition condition, boolean preserved, String name) { + allLocaleBundles.add(name); + } + + @Override + public void addResourceBundles(UnresolvedAccessCondition condition, String basename, Collection locales) { + localizedBundles.add(basename); + } + + @Override + public void addCondition(AccessCondition accessCondition, Module module, String resourcePath) { + } + + @Override + public void addClassBasedResourceBundle(UnresolvedAccessCondition condition, String basename, String className) { + classBasedBundles.add(basename + "=" + className); + } + }; + + ResourceConfigurationParser parser = ResourceConfigurationParser.create(false, AccessConditionResolver.identityResolver(), registry, + EnumSet.of(ConfigurationParserOption.STRICT_CONFIGURATION)); + parser.parseAndRegister(new StringReader(json)); + + Assert.assertEquals(List.of("first.module:com.example.Messages"), localizedBundles); + Assert.assertEquals(List.of("second.module:com.example.Messages=com.example.MessagesBundle"), classBasedBundles); + Assert.assertEquals(List.of("third.module:com.example.Messages"), allLocaleBundles); + } + + @Test + public void moduleQualifiedBundlesRemainDistinctInCombinedConfig() throws IOException { + String json = """ + { + "resources": [ + { + "module": "first.module", + "bundle": "com.example.Messages" + }, + { + "module": "second.module", + "bundle": "com.example.Messages" + } + ] + } + """; + + ResourceConfiguration rc = new ResourceConfiguration(); + rc.createParser(true, EnumSet.of(ConfigurationParserOption.STRICT_CONFIGURATION)).parseAndRegister(new StringReader(json)); + + UnresolvedAccessCondition condition = UnresolvedAccessCondition.unconditional(); + Assert.assertTrue(rc.anyBundleMatches(condition, "first.module", "com.example.Messages")); + Assert.assertTrue(rc.anyBundleMatches(condition, "second.module", "com.example.Messages")); + Assert.assertFalse(rc.anyBundleMatches(condition, "com.example.Messages")); + Assert.assertTrue(rc.supportsCombinedFile()); + + String printed = printJson(rc); + Assert.assertTrue(printed.contains("\"module\":\"first.module\"")); + Assert.assertTrue(printed.contains("\"module\":\"second.module\"")); + Assert.assertTrue(printed.contains("\"bundle\":\"com.example.Messages\"")); + } + + private static String printLegacyJson(ResourceConfiguration rc) { + StringWriter out = new StringWriter(); + try (JsonWriter writer = new JsonWriter(out)) { + rc.printLegacyJson(writer); + } catch (IOException e) { + throw new RuntimeException(e); + } + return out.toString(); + } + + private static String printJson(ResourceConfiguration rc) { + StringWriter out = new StringWriter(); + try (JsonWriter writer = new JsonWriter(out)) { + rc.printJson(writer); + } catch (IOException e) { + throw new RuntimeException(e); + } + return out.toString(); + } } diff --git a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourceConfigurationParser.java b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourceConfigurationParser.java index 3c18278ba637..0cd4f4340d1b 100644 --- a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourceConfigurationParser.java +++ b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourceConfigurationParser.java @@ -73,11 +73,14 @@ protected void parseBundle(Object bundle, boolean inResourcesSection) { String bundleNameAttribute = inResourcesSection ? BUNDLE_KEY : NAME_KEY; checkAttributes(resource, "bundle descriptor object", Collections.singletonList(bundleNameAttribute), Arrays.asList(MODULE_KEY, "locales", "classNames", "condition")); String basename = asString(resource.get(bundleNameAttribute)); + String moduleName = asNullableString(resource.get(MODULE_KEY), MODULE_KEY); + if (moduleName != null && moduleName.isEmpty()) { + moduleName = null; + } TypeResult resolvedAccessCondition = conditionResolver.resolveCondition(parseCondition(resource)); if (!resolvedAccessCondition.isPresent()) { return; } - // TODO GR-67556 - Add full support for MODULE_KEY in ResourceBundle configurations Object locales = resource.get("locales"); if (locales != null) { List asList = asList(locales, "Attribute 'locales' must be a list of locales") @@ -85,7 +88,7 @@ protected void parseBundle(Object bundle, boolean inResourcesSection) { .map(ResourceConfigurationParser::parseLocale) .collect(Collectors.toList()); if (!asList.isEmpty()) { - registry.addResourceBundles(resolvedAccessCondition.get(), basename, asList); + registry.addResourceBundles(resolvedAccessCondition.get(), moduleName, basename, asList); } } @@ -94,12 +97,12 @@ protected void parseBundle(Object bundle, boolean inResourcesSection) { List asList = asList(classNames, "Attribute 'classNames' must be a list of classes"); for (Object o : asList) { String className = asString(o); - registry.addClassBasedResourceBundle(resolvedAccessCondition.get(), basename, className); + registry.addClassBasedResourceBundle(resolvedAccessCondition.get(), moduleName, basename, className); } } if (locales == null && classNames == null) { /* If nothing more precise is specified, register in every included locale */ - registry.addResourceBundles(resolvedAccessCondition.get(), false, basename); + registry.addResourceBundles(resolvedAccessCondition.get(), false, moduleName, basename); } } diff --git a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourcesRegistry.java b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourcesRegistry.java index ae494c77b012..dba9d327fc20 100644 --- a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourcesRegistry.java +++ b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/ResourcesRegistry.java @@ -40,6 +40,10 @@ static ResourcesRegistry singleton() { void addClassBasedResourceBundle(C condition, String basename, String className); + default void addClassBasedResourceBundle(C condition, String moduleName, String basename, String className) { + addClassBasedResourceBundle(condition, qualifyBundleName(moduleName, basename), className); + } + /** * Although the interface-methods below are already defined in the super-interface * {@link RuntimeResourceSupport} they are also needed here for legacy code that accesses them @@ -56,6 +60,20 @@ default void addResources(C condition, String pattern) { @Override void addResourceBundles(C condition, boolean preserved, String name); + @Override + default void addResourceBundles(C condition, boolean preserved, String moduleName, String name) { + RuntimeResourceSupport.super.addResourceBundles(condition, preserved, moduleName, name); + } + @Override void addResourceBundles(C condition, String basename, Collection locales); + + @Override + default void addResourceBundles(C condition, String moduleName, String basename, Collection locales) { + RuntimeResourceSupport.super.addResourceBundles(condition, moduleName, basename, locales); + } + + private static String qualifyBundleName(String moduleName, String bundleName) { + return moduleName != null && !moduleName.isEmpty() ? moduleName + ":" + bundleName : bundleName; + } } diff --git a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/ResourceConfiguration.java b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/ResourceConfiguration.java index ea1df79dbe26..9a62fb85438a 100644 --- a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/ResourceConfiguration.java +++ b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/ResourceConfiguration.java @@ -103,15 +103,30 @@ public void addResourceBundles(UnresolvedAccessCondition condition, boolean pres configuration.addBundle(condition, baseName); } + @Override + public void addResourceBundles(UnresolvedAccessCondition condition, boolean preserved, String moduleName, String baseName) { + configuration.addBundle(condition, moduleName, baseName); + } + @Override public void addResourceBundles(UnresolvedAccessCondition condition, String basename, Collection locales) { configuration.addBundle(condition, basename, locales); } + @Override + public void addResourceBundles(UnresolvedAccessCondition condition, String moduleName, String basename, Collection locales) { + configuration.addBundle(condition, moduleName, basename, locales); + } + @Override public void addClassBasedResourceBundle(UnresolvedAccessCondition condition, String basename, String className) { configuration.addClassResourceBundle(condition, basename, className); } + + @Override + public void addClassBasedResourceBundle(UnresolvedAccessCondition condition, String moduleName, String basename, String className) { + configuration.addClassResourceBundle(condition, moduleName, basename, className); + } } public static final class BundleConfiguration { @@ -146,10 +161,17 @@ public static Comparator comparator() { } } + public record BundleIdentity(String module, String baseName) { + public static Comparator comparator() { + Comparator stringComparator = Comparator.nullsFirst(Comparator.naturalOrder()); + return Comparator.comparing(BundleIdentity::module, stringComparator).thenComparing(BundleIdentity::baseName, stringComparator); + } + } + private final Set> addedResources = Collections.newSetFromMap(new ConcurrentHashMap<>()); private final Set> addedGlobs = Collections.newSetFromMap(new ConcurrentHashMap<>()); private final ConcurrentMap, Pattern> ignoredResources = new ConcurrentHashMap<>(); - private final ConcurrentMap, BundleConfiguration> bundles = new ConcurrentHashMap<>(); + private final ConcurrentMap, BundleConfiguration> bundles = new ConcurrentHashMap<>(); public ResourceConfiguration() { } @@ -158,7 +180,7 @@ public ResourceConfiguration(ResourceConfiguration other) { addedGlobs.addAll(other.addedGlobs); other.addedResources.forEach(this::classifyAndAddPattern); ignoredResources.putAll(other.ignoredResources); - for (Map.Entry, BundleConfiguration> entry : other.bundles.entrySet()) { + for (Map.Entry, BundleConfiguration> entry : other.bundles.entrySet()) { bundles.put(entry.getKey(), new BundleConfiguration(entry.getValue())); } } @@ -210,7 +232,7 @@ public void mergeConditional(UnresolvedAccessCondition condition, ResourceConfig for (Map.Entry, Pattern> entry : other.ignoredResources.entrySet()) { ignoredResources.put(new ConditionalElement<>(condition, entry.getKey().element()), entry.getValue()); } - for (Map.Entry, BundleConfiguration> entry : other.bundles.entrySet()) { + for (Map.Entry, BundleConfiguration> entry : other.bundles.entrySet()) { bundles.put(new ConditionalElement<>(condition, entry.getKey().element()), new BundleConfiguration(entry.getValue())); } } @@ -300,23 +322,35 @@ public void ignoreResourcePattern(UnresolvedAccessCondition condition, String pa } public void addBundle(UnresolvedAccessCondition condition, String basename, Collection locales) { - BundleConfiguration config = getOrCreateBundleConfig(condition, basename); + addBundle(condition, null, basename, locales); + } + + public void addBundle(UnresolvedAccessCondition condition, String moduleName, String basename, Collection locales) { + BundleConfiguration config = getOrCreateBundleConfig(condition, moduleName, basename); for (Locale locale : locales) { config.locales.add(locale.toLanguageTag()); } } public void addBundle(UnresolvedAccessCondition condition, String baseName) { - getOrCreateBundleConfig(condition, baseName); + addBundle(condition, null, baseName); + } + + public void addBundle(UnresolvedAccessCondition condition, String moduleName, String baseName) { + getOrCreateBundleConfig(condition, moduleName, baseName); } private void addClassResourceBundle(UnresolvedAccessCondition condition, String basename, String className) { - getOrCreateBundleConfig(condition, basename).classNames.add(className); + addClassResourceBundle(condition, null, basename, className); + } + + private void addClassResourceBundle(UnresolvedAccessCondition condition, String moduleName, String basename, String className) { + getOrCreateBundleConfig(condition, moduleName, basename).classNames.add(className); } - private BundleConfiguration getOrCreateBundleConfig(UnresolvedAccessCondition condition, String baseName) { - ConditionalElement key = new ConditionalElement<>(condition, baseName); - return bundles.computeIfAbsent(key, cond -> new BundleConfiguration(condition, baseName)); + private BundleConfiguration getOrCreateBundleConfig(UnresolvedAccessCondition condition, String moduleName, String baseName) { + ConditionalElement key = new ConditionalElement<>(condition, new BundleIdentity(moduleName, baseName)); + return bundles.computeIfAbsent(key, cond -> new BundleConfiguration(condition, moduleName, baseName)); } public boolean anyResourceMatches(String s) { @@ -340,7 +374,11 @@ public boolean anyResourceMatches(String s) { } public boolean anyBundleMatches(UnresolvedAccessCondition condition, String bundleName) { - return bundles.containsKey(new ConditionalElement<>(condition, bundleName)); + return anyBundleMatches(condition, null, bundleName); + } + + public boolean anyBundleMatches(UnresolvedAccessCondition condition, String moduleName, String bundleName) { + return bundles.containsKey(new ConditionalElement<>(condition, new BundleIdentity(moduleName, bundleName))); } @Override @@ -350,7 +388,7 @@ public void printJson(JsonWriter writer) throws IOException { if (!addedGlobs.isEmpty()) { writer.appendSeparator(); } - JsonPrinter.printCollection(writer, bundles.keySet(), ConditionalElement.comparator(String::compareTo), (p, w) -> printResourceBundle(bundles.get(p), w, true), false, true); + JsonPrinter.printCollection(writer, bundles.keySet(), ConditionalElement.comparator(BundleIdentity.comparator()), (p, w) -> printResourceBundle(bundles.get(p), w, true), false, true); } } @@ -379,7 +417,7 @@ void printResourcesJson(JsonWriter writer) throws IOException { void printBundlesJson(JsonWriter writer) throws IOException { writer.quote(BUNDLES_KEY).appendFieldSeparator(); - JsonPrinter.printCollection(writer, bundles.keySet(), ConditionalElement.comparator(), (p, w) -> printResourceBundle(bundles.get(p), w, false)); + JsonPrinter.printCollection(writer, bundles.keySet(), ConditionalElement.comparator(BundleIdentity.comparator()), (p, w) -> printResourceBundle(bundles.get(p), w, false)); } void printGlobsJson(JsonWriter writer) throws IOException { @@ -450,7 +488,7 @@ private static void conditionalRegexElementJson(ConditionalElement p, Js public interface Predicate { boolean testIncludedResource(ConditionalElement condition); - boolean testIncludedBundle(ConditionalElement condition, BundleConfiguration bundleConfiguration); + boolean testIncludedBundle(ConditionalElement condition, BundleConfiguration bundleConfiguration); boolean testIncludedGlob(ConditionalElement entry); } diff --git a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/conditional/ConditionalConfigurationPredicate.java b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/conditional/ConditionalConfigurationPredicate.java index be39fd35a2d7..bbd549a2f758 100644 --- a/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/conditional/ConditionalConfigurationPredicate.java +++ b/substratevm/src/com.oracle.svm.configure/src/com/oracle/svm/configure/config/conditional/ConditionalConfigurationPredicate.java @@ -73,7 +73,7 @@ public boolean testIncludedGlob(ConditionalElement condition, ResourceConfiguration.BundleConfiguration bundleConfiguration) { + public boolean testIncludedBundle(ConditionalElement condition, ResourceConfiguration.BundleConfiguration bundleConfiguration) { return !filter.includes(condition.condition().getTypeName()); } diff --git a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/configure/ConfigurationFiles.java b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/configure/ConfigurationFiles.java index 428b74b6fd34..a8694a1dd62b 100644 --- a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/configure/ConfigurationFiles.java +++ b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/configure/ConfigurationFiles.java @@ -108,12 +108,12 @@ public static final class Options { @OptionMigrationMessage("Use a resource-config.json in your META-INF/native-image// directory instead.")// @Option(help = "Files describing Java resources to be included in the image according to the schema at " + - "https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/assets/resource-config-schema-v1.0.0.json", type = OptionType.User)// + "https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/assets/resource-config-schema-v1.1.0.json", type = OptionType.User)// @BundleMember(role = BundleMember.Role.Input)// public static final HostedOptionKey ResourceConfigurationFiles = new HostedOptionKey<>( AccumulatingLocatableMultiOptionValue.Paths.buildWithCommaDelimiter()); @Option(help = "Resources describing Java resources to be included in the image according to the schema at " + - "https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/assets/resource-config-schema-v1.0.0.json", type = OptionType.User)// + "https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/assets/resource-config-schema-v1.1.0.json", type = OptionType.User)// public static final HostedOptionKey ResourceConfigurationResources = new HostedOptionKey<>( AccumulatingLocatableMultiOptionValue.Strings.buildWithCommaDelimiter()); diff --git a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/LocalizationSupport.java b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/LocalizationSupport.java index face963153b6..99338b87bed0 100644 --- a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/LocalizationSupport.java +++ b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/LocalizationSupport.java @@ -78,6 +78,7 @@ */ @SingletonTraits(access = AllAccess.class, layeredCallbacks = NoLayeredCallbacks.class, layeredInstallationKind = Duplicable.class, other = PartiallyLayerAware.class) public class LocalizationSupport { + static final String ALL_UNNAMED_MODULE = "ALL-UNNAMED"; public final Map charsets = new HashMap<>(); @@ -119,21 +120,29 @@ private static Bundles.Strategy getLocaleDataStrategy() { @Platforms(Platform.HOSTED_ONLY.class) public void prepareBundle(String bundleName, ResourceBundle bundle, Function> findModule, Locale locale, boolean jdkBundle) { + String[] bundleNameWithModule = StringUtil.split(bundleName, ":", 2); + String moduleName = bundleNameWithModule.length < 2 ? null : bundleNameWithModule[0]; + String baseName = bundleNameWithModule.length < 2 ? bundleName : bundleNameWithModule[1]; + prepareBundle(moduleName, baseName, bundle, findModule, locale, jdkBundle); + } + + @Platforms(Platform.HOSTED_ONLY.class) + public void prepareBundle(String moduleName, String bundleName, ResourceBundle bundle, Function> findModule, Locale locale, boolean jdkBundle) { /* * Class-based bundle lookup happens on every query, but we don't need to register the * constructor for a property resource bundle since the class lookup will fail. */ - registerRequiredReflectionAndResourcesForBundle(bundleName, Set.of(locale), jdkBundle); + registerRequiredReflectionAndResourcesForBundle(moduleName, bundleName, Set.of(locale), jdkBundle, findModule); if (!(bundle instanceof PropertyResourceBundle)) { registerNullaryConstructor(bundle.getClass()); } /* Property-based bundle lookup happens only if class-based lookup fails */ if (bundle instanceof PropertyResourceBundle) { - String[] bundleNameWithModule = StringUtil.split(bundleName, ":", 2); String resourceName; - String origin = "Added for PropertyResourceBundle: " + bundleName; - if (bundleNameWithModule.length < 2) { + String debugName = qualifyBundleName(moduleName, bundleName); + String origin = "Added for PropertyResourceBundle: " + debugName; + if (moduleName == null || moduleName.isEmpty()) { resourceName = toSlashSeparated(control.toBundleName(bundleName, locale)).concat(".properties"); Map> packageToModules = ImageSingletons.lookup(ClassLoaderSupport.class).getPackageToModules(); @@ -146,10 +155,13 @@ public void prepareBundle(String bundleName, ResourceBundle bundle, Function module = findModule.apply(bundleNameWithModule[0]); + resourceName = toSlashSeparated(control.toBundleName(bundleName, locale)).concat(".properties"); + Optional module = findModule.apply(moduleName); String finalResourceName = resourceName; module.ifPresent(m -> ImageSingletons.lookup(RuntimeResourceSupport.class).addResource(m, finalResourceName, origin)); } @@ -168,6 +180,11 @@ private static String packageName(String bundleName) { } public void registerRequiredReflectionAndResourcesForBundle(String baseName, Collection wantedLocales, boolean jdkBundle) { + registerRequiredReflectionAndResourcesForBundle(null, baseName, wantedLocales, jdkBundle, null); + } + + public void registerRequiredReflectionAndResourcesForBundle(String moduleName, String baseName, Collection wantedLocales, boolean jdkBundle, + Function> findModule) { if (!jdkBundle) { int i = baseName.lastIndexOf('.'); if (i > 0) { @@ -178,11 +195,16 @@ public void registerRequiredReflectionAndResourcesForBundle(String baseName, Col } for (Locale locale : wantedLocales) { - registerRequiredReflectionAndResourcesForBundleAndLocale(baseName, locale, jdkBundle); + registerRequiredReflectionAndResourcesForBundleAndLocale(moduleName, baseName, locale, jdkBundle, findModule); } } public void registerRequiredReflectionAndResourcesForBundleAndLocale(String baseName, Locale baseLocale, boolean jdkBundle) { + registerRequiredReflectionAndResourcesForBundleAndLocale(null, baseName, baseLocale, jdkBundle, null); + } + + public void registerRequiredReflectionAndResourcesForBundleAndLocale(String moduleName, String baseName, Locale baseLocale, boolean jdkBundle, + Function> findModule) { /* * Bundles in the sun.(text|util).resources.cldr packages are loaded with an alternative * strategy which tries parent aliases defined in CLDRBaseLocaleDataMetaInfo.parentLocales. @@ -190,6 +212,7 @@ public void registerRequiredReflectionAndResourcesForBundleAndLocale(String base List candidateLocales = jdkBundle ? getJDKBundleCandidateLocales(baseName, baseLocale) : control.getCandidateLocales(baseName, baseLocale); + Module module = resolveNamedModule(moduleName, findModule); for (Locale locale : candidateLocales) { String bundleWithLocale = jdkBundle ? strategy.toBundleName(baseName, locale) : control.toBundleName(baseName, locale); @@ -198,7 +221,7 @@ public void registerRequiredReflectionAndResourcesForBundleAndLocale(String base if (bundleClass != null) { registerNullaryConstructor(bundleClass); } - Resources.currentLayer().registerNegativeQuery(bundleWithLocale.replace('.', '/') + ".properties"); + Resources.currentLayer().registerNegativeQuery(module, bundleWithLocale.replace('.', '/') + ".properties"); if (jdkBundle) { String otherBundleName = Bundles.toOtherBundleName(baseName, bundleWithLocale, locale); @@ -280,20 +303,54 @@ public void registerBundleLookup(AccessCondition condition, String baseName) { (registered == null ? dynamicAccessMetadata : registered).addCondition(condition); } + @Platforms(Platform.HOSTED_ONLY.class) + public void registerBundleLookup(AccessCondition condition, String moduleName, String baseName) { + registerBundleLookup(condition, qualifyBundleName(moduleName, baseName)); + } + public boolean isRegisteredBundleLookup(String baseName, Locale locale, Object controlOrStrategy) { + return isRegisteredBundleLookup(null, baseName, locale, controlOrStrategy); + } + + public boolean isRegisteredBundleLookup(Module module, String baseName, Locale locale, Object controlOrStrategy) { if (baseName == null || locale == null || controlOrStrategy == null) { /* Those cases will throw a NullPointerException before any lookup */ return true; } if (MetadataTracer.enabled()) { - MetadataTracer.singleton().traceResourceBundle(baseName); + String moduleName = module == null ? null : module.isNamed() ? module.getName() : ALL_UNNAMED_MODULE; + MetadataTracer.singleton().traceResourceBundle(moduleName, baseName); + } + if (module != null && module.isNamed()) { + /* Module-specific registrations are keyed separately from legacy unqualified ones. */ + RuntimeDynamicAccessMetadata registeredModuleBundle = registeredBundles.get(qualifyBundleName(module.getName(), baseName)); + if (registeredModuleBundle != null) { + return registeredModuleBundle.satisfied(); + } + } else if (module != null) { + RuntimeDynamicAccessMetadata registeredUnnamedBundle = registeredBundles.get(qualifyBundleName(ALL_UNNAMED_MODULE, baseName)); + if (registeredUnnamedBundle != null) { + return registeredUnnamedBundle.satisfied(); + } } - if (registeredBundles.containsKey(baseName)) { - return registeredBundles.get(baseName).satisfied(); + RuntimeDynamicAccessMetadata registeredBundle = registeredBundles.get(baseName); + if (registeredBundle != null) { + return registeredBundle.satisfied(); } return false; } + private static String qualifyBundleName(String moduleName, String baseName) { + return moduleName != null && !moduleName.isEmpty() ? moduleName + ":" + baseName : baseName; + } + + private static Module resolveNamedModule(String moduleName, Function> findModule) { + if (moduleName == null || moduleName.isEmpty() || ALL_UNNAMED_MODULE.equals(moduleName) || findModule == null) { + return null; + } + return findModule.apply(moduleName).orElse(null); + } + private static EconomicSet getLanguageTags(EconomicSet locales) { EconomicSet names = EconomicSet.create(); for (Locale locale : locales) { diff --git a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/substitutions/Target_java_util_ResourceBundle.java b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/substitutions/Target_java_util_ResourceBundle.java index 558f54dc802c..996ec7e15771 100644 --- a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/substitutions/Target_java_util_ResourceBundle.java +++ b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/jdk/localization/substitutions/Target_java_util_ResourceBundle.java @@ -61,7 +61,7 @@ private static ResourceBundle getBundleImpl(String baseName, // get resource bundles for a named module only if loader is the module's class loader if (callerModule.isNamed() && loader == getLoader(callerModule)) { - if (!ImageSingletons.lookup(LocalizationSupport.class).isRegisteredBundleLookup(baseName, locale, control)) { + if (!ImageSingletons.lookup(LocalizationSupport.class).isRegisteredBundleLookup(callerModule, baseName, locale, control)) { MissingResourceRegistrationUtils.reportResourceBundleAccess(callerModule, baseName); } return MissingRegistrationUtils.runIgnoringMissingRegistrations(new Supplier() { @@ -80,7 +80,7 @@ public ResourceBundle get() { ? loader.getUnnamedModule() : BootLoader.getUnnamedModule(); - if (!ImageSingletons.lookup(LocalizationSupport.class).isRegisteredBundleLookup(baseName, locale, control)) { + if (!ImageSingletons.lookup(LocalizationSupport.class).isRegisteredBundleLookup(unnamedModule, baseName, locale, control)) { MissingResourceRegistrationUtils.reportResourceBundleAccess(unnamedModule, baseName); } return MissingRegistrationUtils.runIgnoringMissingRegistrations(new Supplier() { @@ -99,11 +99,7 @@ private static ResourceBundle getBundleFromModule(Class caller, Control control) { Objects.requireNonNull(module); Module callerModule = getCallerModule(caller); - /* - * TODO GR-67556 - Implement proper module-aware LocalizationSupport bundle registration to - * ensure we show MissingResourceRegistrationError in all relevant situations. - */ - if (!ImageSingletons.lookup(LocalizationSupport.class).isRegisteredBundleLookup(baseName, locale, control)) { + if (!ImageSingletons.lookup(LocalizationSupport.class).isRegisteredBundleLookup(module, baseName, locale, control)) { MissingResourceRegistrationUtils.reportResourceBundleAccess(module, baseName); } return MissingRegistrationUtils.runIgnoringMissingRegistrations(() -> getBundleImpl(callerModule, module, baseName, locale, control)); diff --git a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/metadata/MetadataTracer.java b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/metadata/MetadataTracer.java index ea006d5435ab..05fd6d424770 100644 --- a/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/metadata/MetadataTracer.java +++ b/substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/metadata/MetadataTracer.java @@ -324,11 +324,19 @@ public void traceResource(String resourceName, String moduleName) { * Marks the given resource bundle within the given locale as reachable. */ public void traceResourceBundle(String baseName) { + traceResourceBundle(null, baseName); + } + + /** + * Marks the given resource bundle within the given module as reachable. + */ + public void traceResourceBundle(String moduleName, String baseName) { assert enabledAtRunTime(); ConfigurationSet configurationSet = getConfigurationSetForTracing(); if (configurationSet != null) { - debug("resource bundle registered", baseName); - configurationSet.getResourceConfiguration().addBundle(UnresolvedAccessCondition.unconditional(), baseName, List.of()); + String debugName = moduleName != null ? moduleName + ":" + baseName : baseName; + debug("resource bundle registered", debugName); + configurationSet.getResourceConfiguration().addBundle(UnresolvedAccessCondition.unconditional(), moduleName, baseName, List.of()); } } diff --git a/substratevm/src/com.oracle.svm.hosted.test/src/com/oracle/svm/hosted/jdk/localization/LocalizationFeatureTest.java b/substratevm/src/com.oracle.svm.hosted.test/src/com/oracle/svm/hosted/jdk/localization/LocalizationFeatureTest.java new file mode 100644 index 000000000000..dd8d2b635608 --- /dev/null +++ b/substratevm/src/com.oracle.svm.hosted.test/src/com/oracle/svm/hosted/jdk/localization/LocalizationFeatureTest.java @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.svm.hosted.jdk.localization; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.ListResourceBundle; +import java.util.Locale; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.concurrent.ConcurrentHashMap; + +import org.graalvm.collections.EconomicMap; +import org.graalvm.collections.EconomicSet; +import org.graalvm.nativeimage.ImageSingletons; +import org.graalvm.nativeimage.dynamicaccess.AccessCondition; +import org.graalvm.nativeimage.hosted.RuntimeResourceAccess; +import org.graalvm.nativeimage.impl.APIDeprecationSupport; +import org.graalvm.nativeimage.impl.ImageSingletonsSupport; +import org.graalvm.nativeimage.impl.RuntimeResourceSupport; +import org.graalvm.nativeimage.impl.TypeReachabilityCondition; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.oracle.svm.core.configure.RuntimeDynamicAccessMetadata; +import com.oracle.svm.core.jdk.localization.LocalizationSupport; +import com.oracle.svm.core.util.UserError; +import com.oracle.svm.hosted.InternalResourceAccess; +import com.oracle.svm.shared.option.HostedOptionValues; + +import jdk.graal.compiler.options.OptionKey; +import jdk.graal.compiler.options.OptionValues; + +public class LocalizationFeatureTest { + private static boolean installedImageSingletonsSupport; + + @BeforeClass + public static void installImageSingletonsSupport() { + installedImageSingletonsSupport = TestImageSingletonsSupport.installIfMissing(); + } + + @AfterClass + public static void restoreImageSingletonsSupport() { + if (installedImageSingletonsSupport) { + TestImageSingletonsSupport.uninstallForTests(); + } + } + + @Test + public void allUnnamedModuleSelectorDoesNotRequireResolution() { + String moduleName = LocalizationFeature.validateBundleModuleName(LocalizationFeature.ALL_UNNAMED_MODULE, "com.example.Messages", moduleToResolve -> { + throw new AssertionError("ALL-UNNAMED should not be resolved as a named module: " + moduleToResolve); + }); + Assert.assertEquals(LocalizationFeature.ALL_UNNAMED_MODULE, moduleName); + } + + @Test + public void missingNamedModuleIsRejected() { + UserError.UserException error = Assert.assertThrows(UserError.UserException.class, + () -> LocalizationFeature.validateBundleModuleName( + "missing.module", + "com.example.Messages", + moduleToResolve -> { + Assert.assertEquals("missing.module", moduleToResolve); + return Optional.empty(); + })); + Assert.assertTrue(error.getMessage().contains("missing.module")); + Assert.assertTrue(error.getMessage().contains("com.example.Messages")); + } + + @Test + public void allUnnamedBundleRegistrationsRemainDistinctFromUnqualifiedLookups() { + LocalizationSupport support = new LocalizationSupport(EconomicSet.create(), StandardCharsets.UTF_8); + support.registerBundleLookup(AccessCondition.unconditional(), LocalizationFeature.ALL_UNNAMED_MODULE, "com.example.Messages"); + + EconomicMap registeredBundles = registeredBundles(support); + Assert.assertNull(registeredBundles.get("com.example.Messages")); + Assert.assertNotNull(registeredBundles.get(LocalizationFeature.ALL_UNNAMED_MODULE + ":com.example.Messages")); + } + + @Test + public void namedBundleRegistrationsRemainModuleQualified() { + LocalizationSupport support = new LocalizationSupport(EconomicSet.create(), StandardCharsets.UTF_8); + Module javaBase = Object.class.getModule(); + support.registerBundleLookup(AccessCondition.unconditional(), javaBase.getName(), "com.example.Messages"); + + EconomicMap registeredBundles = registeredBundles(support); + Assert.assertNotNull(registeredBundles.get(javaBase.getName() + ":com.example.Messages")); + Assert.assertNull(registeredBundles.get("com.example.Messages")); + } + + @Test + public void allUnnamedBundleRegistrationsOnlySatisfyUnnamedModuleLookups() { + LocalizationSupport support = new LocalizationSupport(EconomicSet.create(), StandardCharsets.UTF_8); + support.registerBundleLookup(AccessCondition.unconditional(), LocalizationFeature.ALL_UNNAMED_MODULE, "com.example.Messages"); + + ResourceBundle.Control control = ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_DEFAULT); + Module unnamedModule = LocalizationFeatureTest.class.getClassLoader().getUnnamedModule(); + + Assert.assertTrue(support.isRegisteredBundleLookup(unnamedModule, "com.example.Messages", Locale.ROOT, control)); + Assert.assertFalse(support.isRegisteredBundleLookup(Object.class.getModule(), "com.example.Messages", Locale.ROOT, control)); + } + + @Test + public void runtimeResourceAccessUsesAllUnnamedForUnnamedModules() { + RecordingRuntimeResourceSupport runtimeResources = recordingRuntimeResourceSupport(); + runtimeResources.reset(); + + RuntimeResourceAccess.addResourceBundle(LocalizationFeatureTest.class.getModule(), "com.example.Messages", new Locale[]{Locale.ROOT}); + + Assert.assertEquals(LocalizationFeature.ALL_UNNAMED_MODULE + ":com.example.Messages", runtimeResources.registeredBundleName); + Assert.assertEquals(List.of(Locale.ROOT), runtimeResources.registeredLocales); + } + + @Test + public void resourceAccessBundleRegistrationUsesAllUnnamedForClasspathBundles() { + RecordingRuntimeResourceSupport runtimeResources = recordingRuntimeResourceSupport(); + runtimeResources.reset(); + + ResourceBundle bundle = ResourceBundle.getBundle(ClasspathBundle.class.getName(), Locale.ROOT, LocalizationFeatureTest.class.getClassLoader()); + InternalResourceAccess.singleton().registerResourceBundle(AccessCondition.unconditional(), bundle); + + Assert.assertEquals(ClasspathBundle.class.getName(), bundle.getBaseBundleName()); + Assert.assertEquals(LocalizationFeature.ALL_UNNAMED_MODULE + ":" + ClasspathBundle.class.getName(), runtimeResources.registeredBundleName); + Assert.assertNull(runtimeResources.registeredLocales); + } + + @Test + public void namedModuleMismatchIsRejectedForBundleClass() { + UserError.UserException error = Assert.assertThrows(UserError.UserException.class, + () -> LocalizationFeature.validateBundleClassModule("java.logging", "com.example.Messages", "java.lang.String", String.class)); + Assert.assertTrue(error.getMessage().contains("java.logging")); + Assert.assertTrue(error.getMessage().contains("java.base")); + Assert.assertTrue(error.getMessage().contains("java.lang.String")); + } + + @Test + public void allUnnamedSelectorRejectsNamedBundleClasses() { + UserError.UserException error = Assert.assertThrows(UserError.UserException.class, + () -> LocalizationFeature.validateBundleClassModule(LocalizationFeature.ALL_UNNAMED_MODULE, "com.example.Messages", "java.lang.String", String.class)); + Assert.assertTrue(error.getMessage().contains(LocalizationFeature.ALL_UNNAMED_MODULE)); + Assert.assertTrue(error.getMessage().contains("java.base")); + } + + @Test + public void runtimeCheckedConditionsAreRequiredForBundleLookupMetadata() { + LocalizationSupport support = new LocalizationSupport(EconomicSet.create(), StandardCharsets.UTF_8); + TypeReachabilityCondition condition = TypeReachabilityCondition.create(String.class, false); + + Throwable error = Assert.assertThrows(Throwable.class, + () -> support.registerBundleLookup(condition, "java.base", "com.example.Messages")); + Assert.assertTrue(error.getMessage().contains("runtime conditions")); + } + + @SuppressWarnings("unchecked") + private static EconomicMap registeredBundles(LocalizationSupport support) { + try { + Field field = LocalizationSupport.class.getDeclaredField("registeredBundles"); + field.setAccessible(true); + return (EconomicMap) field.get(support); + } catch (ReflectiveOperationException ex) { + throw new AssertionError(ex); + } + } + + @SuppressWarnings("unchecked") + private static RecordingRuntimeResourceSupport recordingRuntimeResourceSupport() { + return (RecordingRuntimeResourceSupport) ImageSingletons.lookup(RuntimeResourceSupport.class); + } + + public static final class ClasspathBundle extends ListResourceBundle { + @Override + protected Object[][] getContents() { + return new Object[][]{ + {"key", "value"} + }; + } + } + + private static final class RecordingRuntimeResourceSupport implements RuntimeResourceSupport { + private String registeredBundleName; + private List registeredLocales; + + void reset() { + registeredBundleName = null; + registeredLocales = null; + } + + @Override + public void addResources(AccessCondition condition, String pattern, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void addGlob(AccessCondition condition, String module, String glob, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void ignoreResources(AccessCondition condition, String pattern, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void addResourceBundles(AccessCondition condition, boolean preserved, String name) { + registeredBundleName = name; + registeredLocales = null; + } + + @Override + public void addResourceBundles(AccessCondition condition, String basename, Collection locales) { + registeredBundleName = basename; + registeredLocales = List.copyOf(locales); + } + + @Override + public void addCondition(AccessCondition condition, Module module, String resourcePath) { + throw new AssertionError("Unused function."); + } + + @Override + public void addResourceEntry(Module module, String resourcePath, Object origin) { + throw new AssertionError("Unused function."); + } + + @Override + public void injectResource(Module module, String resourcePath, byte[] resourceContent, Object origin) { + throw new AssertionError("Unused function."); + } + } + + private static final class TestImageSingletonsSupport extends ImageSingletonsSupport { + private final ConcurrentHashMap, Object> singletons = new ConcurrentHashMap<>(); + + static boolean installIfMissing() { + if (isInstalled()) { + return false; + } + TestImageSingletonsSupport support = new TestImageSingletonsSupport(); + EconomicMap, Object> values = OptionValues.newOptionMap(); + support.add(HostedOptionValues.class, new HostedOptionValues(values)); + support.add(APIDeprecationSupport.class, new APIDeprecationSupport(false)); + addTestSingleton(support, RuntimeResourceSupport.class, new RecordingRuntimeResourceSupport()); + installSupport(support); + return true; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void addTestSingleton(TestImageSingletonsSupport support, Class key, Object value) { + support.add((Class) key, value); + } + + static void uninstallForTests() { + try { + Field supportField = ImageSingletonsSupport.class.getDeclaredField("support"); + supportField.setAccessible(true); + supportField.set(null, null); + } catch (ReflectiveOperationException ex) { + throw new AssertionError(ex); + } + } + + @Override + public void add(Class key, T value) { + singletons.put(key, value); + } + + @Override + @SuppressWarnings("unchecked") + public T lookup(Class key) { + return (T) singletons.get(key); + } + + @Override + public boolean contains(Class key) { + return singletons.containsKey(key); + } + } +} diff --git a/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/InternalResourceAccess.java b/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/InternalResourceAccess.java index 444857b0fa6f..683a6906e237 100644 --- a/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/InternalResourceAccess.java +++ b/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/InternalResourceAccess.java @@ -36,6 +36,8 @@ public final class InternalResourceAccess implements ResourceAccess { + private static final String ALL_UNNAMED_MODULE = "ALL-UNNAMED"; + private final RuntimeResourceSupport rrsInstance; private static InternalResourceAccess instance; @@ -71,13 +73,16 @@ public void registerResourceBundle(AccessCondition condition, ResourceBundle... } Method m = ReflectionUtil.lookupMethod(cache.getClass(), "getModule"); - Module modul = ReflectionUtil.invokeMethod(m, cache); + Module module = ReflectionUtil.invokeMethod(m, cache); Method m2 = ReflectionUtil.lookupMethod(cache.getClass(), "getName"); String name = ReflectionUtil.invokeMethod(m2, cache); - String finalBundleName = (modul != null && modul.isNamed()) ? modul.getName() + ":" + name : name; - rrsInstance.addResourceBundles(condition, false, finalBundleName); + rrsInstance.addResourceBundles(condition, false, bundleModuleName(module), name); } } + + private static String bundleModuleName(Module module) { + return module != null && module.isNamed() ? module.getName() : ALL_UNNAMED_MODULE; + } } diff --git a/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/ResourcesFeature.java b/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/ResourcesFeature.java index 98a8ddfeb146..4eb80a1c8fe1 100644 --- a/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/ResourcesFeature.java +++ b/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/ResourcesFeature.java @@ -268,18 +268,36 @@ public void addResourceBundles(AccessCondition condition, boolean preserved, Str registerConditionalConfiguration(condition, (cnd) -> ImageSingletons.lookup(LocalizationFeature.class).prepareBundle(cnd, name)); } + @Override + public void addResourceBundles(AccessCondition condition, boolean preserved, String moduleName, String name) { + abortIfSealed(); + registerConditionalConfiguration(condition, (cnd) -> ImageSingletons.lookup(LocalizationFeature.class).prepareBundle(cnd, moduleName, name)); + } + @Override public void addClassBasedResourceBundle(AccessCondition condition, String basename, String className) { abortIfSealed(); registerConditionalConfiguration(condition, _ -> ImageSingletons.lookup(LocalizationFeature.class).prepareClassResourceBundle(basename, className)); } + @Override + public void addClassBasedResourceBundle(AccessCondition condition, String moduleName, String basename, String className) { + abortIfSealed(); + registerConditionalConfiguration(condition, cnd -> ImageSingletons.lookup(LocalizationFeature.class).prepareClassResourceBundle(cnd, moduleName, basename, className)); + } + @Override public void addResourceBundles(AccessCondition condition, String basename, Collection locales) { abortIfSealed(); registerConditionalConfiguration(condition, (cnd) -> ImageSingletons.lookup(LocalizationFeature.class).prepareBundle(cnd, basename, locales)); } + @Override + public void addResourceBundles(AccessCondition condition, String moduleName, String basename, Collection locales) { + abortIfSealed(); + registerConditionalConfiguration(condition, (cnd) -> ImageSingletons.lookup(LocalizationFeature.class).prepareBundle(cnd, moduleName, basename, locales)); + } + /* * It is possible that one resource can be registered under different conditions * (typeReachable). In some cases, few conditions will be satisfied, and we will try to diff --git a/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/jdk/localization/LocalizationFeature.java b/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/jdk/localization/LocalizationFeature.java index ff413f74a10e..9d49c9345f11 100644 --- a/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/jdk/localization/LocalizationFeature.java +++ b/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/jdk/localization/LocalizationFeature.java @@ -34,6 +34,7 @@ import java.util.Locale; import java.util.Map; import java.util.MissingResourceException; +import java.util.Optional; import java.util.ResourceBundle; import java.util.ServiceLoader; import java.util.Set; @@ -108,6 +109,7 @@ @AutomaticallyRegisteredFeature @SingletonTraits(access = BuildtimeAccessOnly.class, layeredCallbacks = NoLayeredCallbacks.class, other = PartiallyLayerAware.class) public class LocalizationFeature implements InternalFeature { + static final String ALL_UNNAMED_MODULE = "ALL-UNNAMED"; /** * Locales required by default in Java. @@ -468,11 +470,41 @@ public void prepareClassResourceBundle(String basename, String className) { support.prepareClassResourceBundle(basename, bundleClass); } + @Platforms(Platform.HOSTED_ONLY.class) + public void prepareClassResourceBundle(AccessCondition condition, String moduleName, String basename, String className) { + String resolvedModuleName = validateBundleModuleName(moduleName, basename, this.imageClassLoader::findModule); + if (moduleName == null || moduleName.isEmpty()) { + prepareClassResourceBundle(basename, className); + return; + } + Class bundleClass = findClassByName.apply(className); + if (bundleClass == null) { + /* Unknown classes are ignored */ + return; + } + UserError.guarantee(ResourceBundle.class.isAssignableFrom(bundleClass), "%s is not a subclass of ResourceBundle", bundleClass.getName()); + validateBundleClassModule(resolvedModuleName, basename, className, bundleClass); + trace("Adding class based resource bundle: " + resolvedModuleName + ":" + className + " " + bundleClass); + support.registerBundleLookup(condition, resolvedModuleName, basename); + support.registerRequiredReflectionAndResourcesForBundle( + resolvedModuleName, + basename, + Set.of(), + false, + this.imageClassLoader::findModule); + support.prepareClassResourceBundle(basename, bundleClass); + } + @Platforms(Platform.HOSTED_ONLY.class) public void prepareBundle(AccessCondition condition, String baseName) { prepareBundle(condition, baseName, allLocales); } + @Platforms(Platform.HOSTED_ONLY.class) + public void prepareBundle(AccessCondition condition, String moduleName, String baseName) { + prepareBundle(condition, moduleName, baseName, allLocales); + } + private static final String[] RESOURCE_EXTENSION_PREFIXES = new String[]{ "sun.text.resources.cldr", "sun.util.resources.cldr", @@ -482,7 +514,23 @@ public void prepareBundle(AccessCondition condition, String baseName) { @Platforms(Platform.HOSTED_ONLY.class) public void prepareBundle(AccessCondition condition, String baseName, Iterable wantedLocales) { - prepareBundleInternal(condition, baseName, wantedLocales); + prepareBundleInternal(condition, null, baseName, wantedLocales); + + String alternativeBundleName = null; + for (String resourceExtensionPrefix : RESOURCE_EXTENSION_PREFIXES) { + if (baseName.startsWith(resourceExtensionPrefix) && !baseName.startsWith(resourceExtensionPrefix + ".ext")) { + alternativeBundleName = baseName.replace(resourceExtensionPrefix, resourceExtensionPrefix + ".ext"); + break; + } + } + if (alternativeBundleName != null) { + prepareBundleInternal(condition, null, alternativeBundleName, wantedLocales); + } + } + + @Platforms(Platform.HOSTED_ONLY.class) + public void prepareBundle(AccessCondition condition, String moduleName, String baseName, Iterable wantedLocales) { + prepareBundleInternal(condition, moduleName, baseName, wantedLocales); String alternativeBundleName = null; for (String resourceExtensionPrefix : RESOURCE_EXTENSION_PREFIXES) { @@ -492,26 +540,28 @@ public void prepareBundle(AccessCondition condition, String baseName, Iterable wantedLocales) { + private void prepareBundleInternal(AccessCondition condition, String moduleName, String baseName, Iterable wantedLocales) { + String resolvedModuleName = validateBundleModuleName(moduleName, baseName, this.imageClassLoader::findModule); boolean somethingFound = false; + String bundleSpec = qualifyBundleName(resolvedModuleName, baseName); for (Locale locale : wantedLocales) { - support.registerBundleLookup(condition, baseName); + support.registerBundleLookup(condition, resolvedModuleName, baseName); List resourceBundle; try { - resourceBundle = ImageSingletons.lookup(ClassLoaderSupport.class).getResourceBundle(baseName, locale); + resourceBundle = ImageSingletons.lookup(ClassLoaderSupport.class).getResourceBundle(bundleSpec, locale); } catch (MissingResourceException mre) { for (Locale candidateLocale : support.control.getCandidateLocales(baseName, locale)) { - prepareNegativeBundle(condition, baseName, candidateLocale, false); + prepareNegativeBundle(condition, resolvedModuleName, baseName, candidateLocale, false); } continue; } somethingFound |= !resourceBundle.isEmpty(); for (ResourceBundle bundle : resourceBundle) { - prepareBundle(condition, baseName, bundle, locale, false); + prepareBundle(condition, resolvedModuleName, baseName, bundle, locale, false); } } @@ -522,6 +572,7 @@ private void prepareBundleInternal(AccessCondition condition, String baseName, I */ Class clazz = findClassByName.apply(baseName); if (clazz != null && ResourceBundle.class.isAssignableFrom(clazz)) { + validateBundleClassModule(resolvedModuleName, baseName, baseName, clazz); trace("Found non-compliant class-based bundle " + clazz); try { support.prepareNonCompliant(clazz); @@ -540,13 +591,13 @@ private void prepareBundleInternal(AccessCondition condition, String baseName, I "If the bundle is part of a module, verify the bundle name is a fully qualified class name. Otherwise " + "verify the bundle path is accessible in the classpath."; trace(errorMessage); - prepareNegativeBundle(condition, baseName, Locale.ROOT, false); + prepareNegativeBundle(condition, resolvedModuleName, baseName, Locale.ROOT, false); for (Locale locale : wantedLocales) { - prepareNegativeBundle(condition, baseName, Locale.of(locale.getLanguage()), false); + prepareNegativeBundle(condition, resolvedModuleName, baseName, Locale.of(locale.getLanguage()), false); } for (Locale locale : wantedLocales) { if (!locale.getCountry().isEmpty()) { - prepareNegativeBundle(condition, baseName, locale, false); + prepareNegativeBundle(condition, resolvedModuleName, baseName, locale, false); } } } @@ -554,18 +605,24 @@ private void prepareBundleInternal(AccessCondition condition, String baseName, I @Platforms(Platform.HOSTED_ONLY.class) protected void prepareNegativeBundle(AccessCondition condition, String baseName, Locale locale, boolean jdkBundle) { - support.registerBundleLookup(condition, baseName); - support.registerRequiredReflectionAndResourcesForBundleAndLocale(baseName, locale, jdkBundle); + prepareNegativeBundle(condition, null, baseName, locale, jdkBundle); + } + + @Platforms(Platform.HOSTED_ONLY.class) + protected void prepareNegativeBundle(AccessCondition condition, String moduleName, String baseName, Locale locale, boolean jdkBundle) { + support.registerBundleLookup(condition, moduleName, baseName); + support.registerRequiredReflectionAndResourcesForBundleAndLocale(moduleName, baseName, locale, jdkBundle, this.imageClassLoader::findModule); } @Platforms(Platform.HOSTED_ONLY.class) protected void prepareJDKBundle(ResourceBundle bundle, Locale locale) { String baseName = bundle.getBaseBundleName(); - prepareBundle(AccessCondition.unconditional(), baseName, bundle, locale, true); + prepareBundle(AccessCondition.unconditional(), null, baseName, bundle, locale, true); } @Platforms(Platform.HOSTED_ONLY.class) - private void prepareBundle(AccessCondition condition, String bundleName, ResourceBundle bundle, Locale locale, boolean jdkBundle) { + private void prepareBundle(AccessCondition condition, String moduleName, String baseName, ResourceBundle bundle, Locale locale, boolean jdkBundle) { + String bundleName = qualifyBundleName(moduleName, baseName); trace("Adding bundle " + bundleName + ", locale " + locale + " with condition " + condition); /* * Ensure that the bundle contents are loaded. We need to walk the whole bundle parent chain @@ -573,14 +630,59 @@ private void prepareBundle(AccessCondition condition, String bundleName, Resourc */ for (ResourceBundle cur = bundle; cur != null; cur = SharedSecrets.getJavaUtilResourceBundleAccess().getParent(cur)) { /* Register all bundles with their corresponding locales */ - support.prepareBundle(bundleName, cur, this.imageClassLoader::findModule, cur.getLocale(), jdkBundle); + support.prepareBundle(moduleName, baseName, cur, this.imageClassLoader::findModule, cur.getLocale(), jdkBundle); } /* * Finally, register the requested bundle with requested locale (Requested might be more * specific than the actual bundle locale */ - support.prepareBundle(bundleName, bundle, this.imageClassLoader::findModule, locale, jdkBundle); + support.prepareBundle(moduleName, baseName, bundle, this.imageClassLoader::findModule, locale, jdkBundle); + } + + private static String qualifyBundleName(String moduleName, String baseName) { + return moduleName != null && !moduleName.isEmpty() ? moduleName + ":" + baseName : baseName; + } + + static String validateBundleModuleName(String moduleName, String baseName, Function> findModule) { + if (moduleName == null || moduleName.isEmpty()) { + return null; + } + if (ALL_UNNAMED_MODULE.equals(moduleName)) { + return moduleName; + } + if (findModule.apply(moduleName).isEmpty()) { + throw UserError.abort( + "Resource bundle '%s' was configured with module '%s', but that module is not present on the image classpath or module path.", + baseName, + moduleName); + } + return moduleName; + } + + static void validateBundleClassModule(String moduleName, String bundleName, String className, Class bundleClass) { + if (moduleName == null || moduleName.isEmpty()) { + return; + } + Module actualModule = bundleClass.getModule(); + if (ALL_UNNAMED_MODULE.equals(moduleName)) { + throwIfModuleMismatch(!actualModule.isNamed(), bundleName, className, moduleName, actualModule); + return; + } + throwIfModuleMismatch(actualModule.isNamed() && moduleName.equals(actualModule.getName()), bundleName, className, moduleName, actualModule); + } + + private static void throwIfModuleMismatch(boolean valid, String bundleName, String className, String expectedModuleName, Module actualModule) { + if (valid) { + return; + } + String actualModuleName = actualModule.isNamed() ? actualModule.getName() : ALL_UNNAMED_MODULE; + throw UserError.abort( + "Resource bundle '%s' was configured with module '%s', but class '%s' belongs to module '%s'.", + bundleName, + expectedModuleName, + className, + actualModuleName); } @Platforms(Platform.HOSTED_ONLY.class)