Skip to content
Open
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
17 changes: 16 additions & 1 deletion src/main/java/jakarta/validation/Validation.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

import jakarta.validation.bootstrap.GenericBootstrap;
import jakarta.validation.bootstrap.ProviderSpecificBootstrap;
import jakarta.validation.internal.ValidationPackageOpenerImpl;
import jakarta.validation.spi.BootstrapState;
import jakarta.validation.spi.ValidationPackageOpener;
import jakarta.validation.spi.ValidationProvider;

/**
Expand Down Expand Up @@ -215,6 +217,7 @@ public T configure() {

try {
U provider = validationProviderClass.getDeclaredConstructor().newInstance();
state.providerModule = provider.getClass().getModule();
return provider.createSpecializedConfiguration( state );
}
catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException | RuntimeException e) {
Expand All @@ -237,6 +240,7 @@ public T configure() {
for ( ValidationProvider<?> provider : resolvers ) {
if ( validationProviderClass.isAssignableFrom( provider.getClass() ) ) {
ValidationProvider<T> specificProvider = validationProviderClass.cast( provider );
state.providerModule = specificProvider.getClass().getModule();
return specificProvider.createSpecializedConfiguration( state );

}
Expand All @@ -250,6 +254,7 @@ private static class GenericBootstrapImpl implements GenericBootstrap, Bootstrap

private ValidationProviderResolver resolver;
private ValidationProviderResolver defaultResolver;
private Module providerModule;

@Override
public GenericBootstrap providerResolver(ValidationProviderResolver resolver) {
Expand All @@ -270,6 +275,14 @@ public ValidationProviderResolver getDefaultValidationProviderResolver() {
return defaultResolver;
}

@Override
public ValidationPackageOpener getPackageOpener() {
if ( providerModule == null || !providerModule.isNamed() ) {
return (providerLookup, targetModule, packageName) -> { };
}
return new ValidationPackageOpenerImpl( providerModule );
}

@Override
public Configuration<?> configure() {
ValidationProviderResolver resolver = this.resolver == null ?
Expand Down Expand Up @@ -297,7 +310,9 @@ public Configuration<?> configure() {

Configuration<?> config;
try {
config = resolver.getValidationProviders().get( 0 ).createGenericConfiguration( this );
final ValidationProvider<?> provider = resolver.getValidationProviders().get( 0 );
this.providerModule = provider.getClass().getModule();
config = provider.createGenericConfiguration( this );
}
catch ( RuntimeException re ) {
throw new ValidationException( "Unable to instantiate Configuration.", re );
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Jakarta Validation API
*
* License: Apache License, Version 2.0
* See the license.txt file in the root directory or <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package jakarta.validation.internal;

import java.lang.invoke.MethodHandles;
import java.util.Objects;

import jakarta.validation.spi.PackageAccessException;
import jakarta.validation.spi.ValidationPackageOpener;

/**
* Implementation of {@link ValidationPackageOpener} bound to a specific provider module.
* <p>
* This class resides in a non-exported package so that it cannot be accessed,
* subclassed, or instantiated from outside the {@code jakarta.validation} module.
* <p>
* The {@link Module#addOpens(String, Module)} call is {@code @CallerSensitive};
* because this class belongs to the {@code jakarta.validation} module, the JVM
* recognizes it as a valid caller when the user has opened a package to
* {@code jakarta.validation}.
*
* @since 4.0
*/
public final class ValidationPackageOpenerImpl implements ValidationPackageOpener {

private final Module providerModule;

/**
* @param providerModule the module of the validation provider discovered
* during bootstrap; this opener will only open packages to this module
*/
public ValidationPackageOpenerImpl(Module providerModule) {
this.providerModule = Objects.requireNonNull( providerModule );
}

@Override
public void openPackage(MethodHandles.Lookup providerLookup, Module targetModule, String packageName) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to ensure this method is only ever called from the module of the provider?

We could use StackWalker to find all the stack frames until the provider module is reached and assert that there are no stack frames from other modules, which are not reflection related. That way, we'd ensure that only the provider can call this, as an additional safety net.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to ensure this method is only ever called from the module of the provider

+1 , that definitely was the intention, yes! I thought that I got it addressed by:

		if ( !providerLookup.lookupClass().getModule().equals( providerModule ) ) {

the idea being that we pass the lookup created at a callsite opener.openPackage(MethodHandles.lookup(), ...) and as this carries the info of who called the lookup + it cannot be "faked" we get that safty net ? Or did you mean to add the stack walker as an extra check to this one?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about adding the StackWalker code as an extra safety, but it seems like your proposed solution is good enough, assuming safe usage from the provider.

Objects.requireNonNull( providerLookup );
Objects.requireNonNull( targetModule );
Objects.requireNonNull( packageName );

if ( !providerLookup.hasFullPrivilegeAccess() ) {
throw new PackageAccessException(
"ValidationPackageOpener requires a full-privilege Lookup (obtained via MethodHandles.lookup())"
);
}

if ( !providerLookup.lookupClass().getModule().equals( providerModule ) ) {
throw new PackageAccessException(
"Lookup class module " + providerLookup.lookupClass().getModule().getName()
+ " does not match the bound provider module " + providerModule.getName()
);
}

if ( !targetModule.isNamed() ) {
return;
}

try {
if ( !targetModule.isOpen( packageName, providerModule ) ) {
targetModule.addOpens( packageName, providerModule );
}
}
catch (IllegalCallerException e) {
throw new PackageAccessException(
"Cannot open package " + packageName + " in module " + targetModule.getName()
+ " to provider module " + providerModule.getName()
+ ". Ensure the module has 'opens " + packageName + " to jakarta.validation'",
e
);
}
}
}
10 changes: 10 additions & 0 deletions src/main/java/jakarta/validation/internal/package-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Jakarta Validation API
*
* License: Apache License, Version 2.0
* See the license.txt file in the root directory or <http://www.apache.org/licenses/LICENSE-2.0>.
*/
/**
* Internal implementation package -- not exported and not part of the public API.
*/
package jakarta.validation.internal;
13 changes: 13 additions & 0 deletions src/main/java/jakarta/validation/spi/BootstrapState.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,17 @@ public interface BootstrapState {
* @return default implementation of ValidationProviderResolver
*/
ValidationProviderResolver getDefaultValidationProviderResolver();

/**
* Returns a {@link ValidationPackageOpener} that can relay JPMS package-opens
* through the {@code jakarta.validation} module to the validation
* provider module discovered during bootstrap.
* <p>
* In non-modular (classpath) environments, the returned opener is a
* no-op and can be safely invoked without effect.
*
* @return a {@code ValidationPackageOpener} instance; never {@code null}
* @since 4.0
*/
ValidationPackageOpener getPackageOpener();
}
42 changes: 42 additions & 0 deletions src/main/java/jakarta/validation/spi/PackageAccessException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Jakarta Validation API
*
* License: Apache License, Version 2.0
* See the license.txt file in the root directory or <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package jakarta.validation.spi;

import jakarta.validation.ValidationException;

import java.io.Serial;

/**
* Exception raised when a {@link jakarta.validation.spi.ValidationPackageOpener}
* cannot relay package access to the validation provider module.
* <p>
* Typical causes include a missing or invalid {@link java.lang.invoke.MethodHandles.Lookup}
* and a package that has not been opened to the {@code jakarta.validation} module.
*
* @since 4.0
*/
public class PackageAccessException extends ValidationException {

@Serial
private static final long serialVersionUID = 1L;

public PackageAccessException() {
super();
}

public PackageAccessException(String message) {
super( message );
}

public PackageAccessException(Throwable cause) {
super( cause );
}

public PackageAccessException(String message, Throwable cause) {
super( message, cause );
}
}
73 changes: 73 additions & 0 deletions src/main/java/jakarta/validation/spi/ValidationPackageOpener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Jakarta Validation API
*
* License: Apache License, Version 2.0
* See the license.txt file in the root directory or <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package jakarta.validation.spi;

import java.lang.invoke.MethodHandles;

/**
* Callback provided by the Jakarta Validation bootstrap to relay
* JPMS package-opens through the {@code jakarta.validation} module to a
* validation provider module.
* <p>
* When a user module opens a package to {@code jakarta.validation}
* via a qualified {@code opens ... to jakarta.validation} directive,
* this callback relays that access to the validation provider's module,
* so the user never needs to name the provider module directly.
* <p>
* The opener is bound to the provider module that was discovered during
* bootstrap. The {@link MethodHandles.Lookup} parameter serves as an
* unforgeable proof of caller identity: the implementation verifies
* that the lookup has {@linkplain MethodHandles.Lookup#hasFullPrivilegeAccess()
* full privilege access} and that its
* {@linkplain MethodHandles.Lookup#lookupClass() lookup class} belongs
* to the bound provider module.
* <p>
* <b>Usage by providers:</b>
* <pre>
* // During bootstrap, obtain the opener from BootstrapState
* ValidationPackageOpener opener = bootstrapState.getPackageOpener();
*
* // Only relay when the user module has opened the package to jakarta.validation
* Module targetModule = beanClass.getModule();
* String packageName = beanClass.getPackageName();
* if (targetModule.isOpen(packageName, ValidationPackageOpener.class.getModule())) {
* // Create lookup at the call site — do not cache as a static field
* opener.openPackage(MethodHandles.lookup(), targetModule, packageName);
* }
* </pre>
* <p>
* In non-modular environments (classpath), invoking this callback
* is a safe no-op.
* <p>
* If the provider needs to further relay access to a third-party
* library module, it can call {@link Module#addOpens(String, Module)}
* from its own code after obtaining access through this callback.
*
* @since 4.0
*/
@FunctionalInterface
public interface ValidationPackageOpener {

/**
* Opens {@code packageName} in {@code targetModule} to the bound
* provider module, provided that the package is open to the
* {@code jakarta.validation} module and not already open to the
* provider.
*
* @param providerLookup a full-privilege {@link MethodHandles.Lookup}
* obtained via {@code MethodHandles.lookup()} in the provider's
* code; used to verify the caller's identity
* @param targetModule the module containing the package to open
* (typically the user's module)
* @param packageName the fully qualified package name to open
* (e.g. {@code "com.example.beans"})
* @throws PackageAccessException if the lookup does not
* have full privilege access, does not belong to the bound
* provider module, or the package could not be opened
*/
void openPackage(MethodHandles.Lookup providerLookup, Module targetModule, String packageName);
}
15 changes: 4 additions & 11 deletions src/test/java/jakarta/validation/FooValidationProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,6 @@
import java.util.ArrayList;
import java.util.List;

import jakarta.validation.BootstrapConfiguration;
import jakarta.validation.ClockProvider;
import jakarta.validation.Configuration;
import jakarta.validation.ConstraintValidatorFactory;
import jakarta.validation.MessageInterpolator;
import jakarta.validation.ParameterNameProvider;
import jakarta.validation.TraversableResolver;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorContext;
import jakarta.validation.ValidatorFactory;
import jakarta.validation.FooValidationProvider.DummyConfiguration;
import jakarta.validation.spi.BootstrapState;
import jakarta.validation.spi.ConfigurationState;
Expand All @@ -32,18 +22,21 @@
*/
public class FooValidationProvider implements ValidationProvider<DummyConfiguration> {
public static List<SoftReference<FooValidationProvider>> createdValidationProviders = new ArrayList<>();
public static volatile BootstrapState latestBootstrapState;

public FooValidationProvider() {
createdValidationProviders.add( new SoftReference<>( this ) );
}

@Override
public DummyConfiguration createSpecializedConfiguration(BootstrapState state) {
return null;
latestBootstrapState = state;
return new DummyConfiguration();
}

@Override
public DummyConfiguration createGenericConfiguration(BootstrapState state) {
latestBootstrapState = state;
return new DummyConfiguration();
}

Expand Down
40 changes: 36 additions & 4 deletions src/test/java/jakarta/validation/ValidationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static org.testng.Assert.fail;

import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.lang.ref.SoftReference;
import java.net.URL;
import java.net.URLClassLoader;
Expand All @@ -23,11 +24,8 @@

import org.testng.annotations.Test;

import jakarta.validation.NoProviderFoundException;
import jakarta.validation.Validation;
import jakarta.validation.ValidationProviderResolver;
import jakarta.validation.ValidatorFactory;
import jakarta.validation.NonRegisteredValidationProvider.NonRegisteredConfiguration;
import jakarta.validation.spi.ValidationPackageOpener;
import jakarta.validation.spi.ValidationProvider;

/**
Expand Down Expand Up @@ -154,6 +152,40 @@ public void testNoProviderFoundThrowsNoProviderFoundException() {
.buildValidatorFactory();
}

@Test
public void testPackageOpenerAvailableFromBootstrapState() {
FooValidationProvider.latestBootstrapState = null;
Validation.buildDefaultValidatorFactory();
assertNotNull( FooValidationProvider.latestBootstrapState, "BootstrapState should have been captured" );

ValidationPackageOpener opener = FooValidationProvider.latestBootstrapState.getPackageOpener();
assertNotNull( opener, "ValidationPackageOpener should not be null" );
}

@Test
public void testPackageOpenerNoOpOnUnnamedModule() {
FooValidationProvider.latestBootstrapState = null;
Validation.buildDefaultValidatorFactory();
ValidationPackageOpener opener = FooValidationProvider.latestBootstrapState.getPackageOpener();

// In classpath mode all classes are in the unnamed module; openPackage should be a no-op
opener.openPackage(
MethodHandles.lookup(),
getClass().getModule(),
getClass().getPackageName()
);
}

@Test
public void testPackageOpenerAvailableFromProviderSpecificBootstrap() {
FooValidationProvider.latestBootstrapState = null;
Validation.byProvider( FooValidationProvider.class ).configure();
assertNotNull( FooValidationProvider.latestBootstrapState, "BootstrapState should have been captured" );

ValidationPackageOpener opener = FooValidationProvider.latestBootstrapState.getPackageOpener();
assertNotNull( opener, "ValidationPackageOpener should not be null for provider-specific bootstrap" );
}

private int countInMemoryProviders() {
int count = 0;
// we cannot access Validation.DefaultValidationProviderResolver#providersPerClassloader, so we have to
Expand Down
Loading