Fix marshalling of non-public classes and Serializable beans - #16296
Fix marshalling of non-public classes and Serializable beans#16296sbglasius wants to merge 4 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16296 +/- ##
==================================================
+ Coverage 54.7302% 54.8230% +0.0929%
- Complexity 20538 20592 +54
==================================================
Files 2104 2105 +1
Lines 101149 101202 +53
Branches 17966 17980 +14
==================================================
+ Hits 55359 55482 +123
+ Misses 37772 37664 -108
- Partials 8018 8056 +38
🚀 New features to boost your workflow:
|
| * Hands out instances of Groovy classes that are not public, but whose read methods are. This is the | ||
| * shape produced by an anonymous implementation of a public interface inside a Grails controller. | ||
| */ | ||
| @CompileStatic |
There was a problem hiding this comment.
Question: Any need to test a Groovy bean that isn't @CompileStatic? Probably not, but I thought I'd ask the question anyway.
There was a problem hiding this comment.
It doesn't make a difference if it's @CompileStatic or not. Reflection wise the class is very similar looking.
There was a problem hiding this comment.
A DynamicGroovyPersonFactory was added, just to be on the safe side.
| if (readMethod.getAnnotation(PersistenceMethod.class) != null) continue; | ||
| if (readMethod.getAnnotation(ControllerMethod.class) != null) continue; | ||
| Object value = readMethod.invoke(o, (Object[]) null); | ||
| Method invokableMethod = ClassUtils.getInterfaceMethodIfPossible(readMethod, clazz); |
There was a problem hiding this comment.
Doesn't this make it permanently accessible and not just for this code?
There was a problem hiding this comment.
Yes, it does, but only for non-interface classes. The full answer goes like this:
== beans.PkgWithInterface (class public: false)
raw read method canAccess from this package: false
getInterfaceMethodIfPossible -> beans.Public (same object as raw: false)
resolved canAccess BEFORE makeAccessible: true <- already accessible
resolved canAccess AFTER makeAccessible: true
the raw class-declared method still canAccess: false <- untouched
== beans.PkgNoInterface (class public: false)
getInterfaceMethodIfPossible -> beans.PkgNoInterface (same object as raw: true)
resolved canAccess BEFORE makeAccessible: false
resolved canAccess AFTER makeAccessible: true <- flag flipped
a later BeanUtils+resolve sees it accessible: true (same object: true)
a FRESH getDeclaredMethod copy canAccess: false <- not globally opened
- Interface case (the reported bug — anonymous
UserDetails):getInterfaceMethodIfPossiblehands back a different Method, the interface's, which is public-on-public.ReflectionUtils.makeAccessibleshort-circuits and nothing is mutated. The class's own read method stays inaccessible. - No-interface case (package-private class, public getter, nothing to resolve to): yes,
setAccessible(true)fires and it persists. Two bounds on how far:- The flag lives on the Method instance, not on class metadata.
BeanUtils.getPropertyDescriptorsis backed by the staticCachedIntrospectionResultscache and returns the same instance every call (confirmed above), so anything else in the JVM that asks Spring for that class's descriptors gets an already-invokable method, for the lifetime of that cache. - A fresh
getDeclaredMethod/getMethodcopy is still false. The member is not globally opened, and this grants nothing a caller couldn't get itself — any classpath code in the unnamed module cansetAccessiblea public method of a non-public class. It's also the pattern Spring itself uses on cached members (AutowiredAnnotationBeanPostProcessor,AbstractNestablePropertyAccessor).
- The flag lives on the Method instance, not on class metadata.
If you'd rather not mutate shared cached state at all, the clean alternative is to work on our own copy — clazz.getDeclaredMethod(name) returns a new instance per call (also confirmed above), so setAccessible on that leaks nowhere. Cost is one extra reflective lookup per property per marshal unless we cache it ourselves.
There was a problem hiding this comment.
I'm honestly not sure in this case. I'm hoping we can discuss in the weekly - instead of fixing this, why not force people to make those inner classes public?
There was a problem hiding this comment.
@jdaugherty I don't feel we reached an agreement on the weekly?
And with regards to a case, where access is widened, is that even a problem. Every developer can do so them self, using the exact same techniques.
We could also easily go for the "not mutated" case.
IMO Grails does not have to become the framework where everything has to be according to standards.
There was a problem hiding this comment.
I don't see much downside to going the "not mutated" case but I acknowledge the concern may be performance, as you put it: "Cost is one extra reflective lookup per property per marshal." I think this is an acceptable cost, but it's easy for me to say that as I don't have visibility into that potential cost for other people. I will accept that low cost. I'm a thumbs up on the "not mutated" case suggestion.
|
@sbglasius is this a result of Groovy now honoring the modifiers where previously it would treat package private / protected as public? |
Yes, it is because Groovy 4 stamped ACC_PUBLIC on anonymous inner classes, Groovy 5 keeps them package-private. It would actually show in Groovy 4, if a class was marked |
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
c3446ef to
98fb828
Compare
|
I question if we should instead just force people to update their code to make those inner classes public. @sbglasius I think you said the plugin itself didn't do this, can't we fix that instead? Otherwise, we're encouraging bad habbits as newer versions of groovy are released. (I am ultimately ok with this change, but I'm playing devil's advocate here to ensure we're not introducing something that should just be a note in the upgrade guide). I'm also curious what @matrei thinks on this. |
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
98fb828 to
cc6f5c8
Compare
I was under the impression that more than just inner classes are in play, such as anonymous classes? |
|
@jdaugherty What @bkoehm is a good point. It's not just non-public classes that's the issue, also anonymous classes. Does that change your mind? |
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
cc6f5c8 to
ebe1cf1
Compare
Marshalling `someObject as JSON` (or `as XML`) failed for two common shapes of object. A class that is not public — anonymous, local or package-private — cannot have its read methods invoked reflectively from another package, even though the methods themselves are public. All four bean marshallers called `readMethod.invoke(...)` bare, so handing `as JSON` an anonymous implementation of a public interface (the usual shape of a Spring Security `UserDetails`) blew up with an `IllegalAccessException`. The read method is now resolved to the interface method where one exists and made accessible otherwise. The public field loops of both `GroovyBeanMarshaller`s failed the same way and are now made accessible too. `GenericJavaBeanMarshaller` evaluated `field.canAccess(o)` before the static check, and `canAccess` throws `IllegalArgumentException` for a static member when the object is non-null. Any bean declaring `private static final long serialVersionUID` — nearly every Serializable bean — therefore failed. The modifier checks now run first so they short-circuit. Groovy compiles the variables captured by an anonymous class into public synthetic `Reference` fields. Now that the field loops can read them, they were emitted as duplicate keys with empty-object values, so synthetic fields are skipped in all four marshallers. Fixes #16294 Fixes #16295
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
ebe1cf1 to
e6920c6
Compare
`ReflectionUtils.makeAccessible` flips the flag on the `Method` instance that Spring's `CachedIntrospectionResults` hands back, and that instance is shared: every later `BeanUtils.getPropertyDescriptors` call for the class returns the same already-invokable object, for the lifetime of the cache. Re-resolve the method from its declaring class instead. `getDeclaredMethod` returns a fresh copy per call, so `setAccessible` touches nothing but our own copy. The extra lookup only happens when access is actually denied — the common case, a read method resolved to a public interface method, is already invokable and pays nothing. The field loops need no equivalent change: `getDeclaredFields` already returns a fresh copy on every call, so widening one never escapes. Both specs gain a test that reads the cached descriptor back after marshalling and fails if the flag was flipped. Raised in review of #16296.
matrei
left a comment
There was a problem hiding this comment.
AI Review
Overview
Fixes two production-visible bugs in the four JSON/XML bean marshallers under grails-converters (#16294, #16295). The problem shapes are extremely common — Spring Security–style anonymous UserDetails implementations, any Serializable bean with serialVersionUID — so this is a real quality-of-life fix.
Three concerns are addressed:
- Non-public read methods —
readMethod.invoke(o, …)failed withIllegalAccessExceptionwhen the declaring class wasn't accessible from another package, even if the method itself was public. Resolved by walking to the interface method viaClassUtils.getInterfaceMethodIfPossible, and falling back to reflectively widening a fresh copy of the method when no interface match exists. Field.canAccess(o)throws on static fields with a non-nullobj— the JDK contract explicitly throwsIllegalArgumentExceptionin that case, so any bean declaringprivate static final long serialVersionUIDblew up. Fixed by short-circuiting the modifier checks beforecanAccess.- Groovy synthetic capture fields — anonymous inner classes compile enclosing variables into public synthetic
Referencefields. Once the field loops can read them, they emitted as duplicate keys with empty-object values. All four marshallers now filterfield.isSynthetic().
All four marshallers get symmetric treatment. New specs (NonPublicClassMarshallingSpec for JSON and XML) plus a dynamic-Groovy fixture cover the paths, and each spec has a dedicated assertion that the shared descriptor cache is not mutated.
Correctness
- Interface-method resolution:
ClassUtils.getInterfaceMethodIfPossible(readMethod, clazz)never returns null (falls back to the original), and virtual dispatch oninvoke(o, …)still calls the overridden concrete method. This is exactly how Spring resolves inaccessible-class calls internally. - Static-field short-circuit:
!Modifier.isStatic(modifiers)is evaluated beforefield.canAccess(o)in each marshaller, so thecanAccessexception on static fields is avoided. - Synthetic filter: necessary now that field access is widened in the Groovy marshallers, otherwise Groovy's
Referencecapture fields would leak. The XML spec explicitly assertsxml.count('<name>') == 1, which locks this in. - Cache non-mutation (the important one for this codebase, given how many callers hit
BeanUtils.getPropertyDescriptors):Method invokableMethod = ClassUtils.getInterfaceMethodIfPossible(readMethod, clazz); if (!invokableMethod.canAccess(o)) { // Widen a private copy, so the flag never leaks into the shared descriptor cache invokableMethod = invokableMethod.getDeclaringClass().getDeclaredMethod(invokableMethod.getName()); invokableMethod.setAccessible(true); }
Class.getDeclaredMethodallocates a newMethodper call (viaMethod.copy()in the JDK), sosetAccessible(true)on the copy cannot flow back to Spring'sCachedIntrospectionResults. The dedicated spec method that reads the cached descriptor back after marshalling and asserts the flag is still down is the right way to lock this in. - Fields don't need the same treatment:
Class.getDeclaredFields()already returns fresh copies each call, so theReflectionUtils.makeAccessible(field)calls in the twoGroovyBeanMarshallers can't leak. Correctly identified and called out in the commit message. - Existing safeguards preserved:
PersistenceMethod/ControllerMethodskips,metaClass/classfiltering, and GORMattached/errorsfiltering are untouched.
Suggestions
-
Java vs Groovy field-access asymmetry — worth explicitly deciding on:
GroovyBeanMarshaller(both): drops thefield.canAccess(o)guard and unconditionally callsReflectionUtils.makeAccessible(field)beforefield.get(o).GenericJavaBeanMarshaller(both): keepsfield.canAccess(o)and does not widen.
Consequence: a Java anonymous class with a public field on a non-public declaring class will have that field silently skipped, while its Groovy equivalent (
anonymousPersonWithPublicField) is emitted. If that asymmetry is intentional, fine — but the tests don't cover the Java-side variant, so it's easy to regress. Consider either aligning behavior (widen on both) or adding a test that asserts the current Java behavior on purpose. -
Duplicated widening block across four files — the same five lines and the same comment appear in all four marshallers. Consider a small helper to keep the intent centralized:
private static Method resolveInvokable(Method readMethod, Class<?> targetClass, Object target) throws NoSuchMethodException { Method invokable = ClassUtils.getInterfaceMethodIfPossible(readMethod, targetClass); if (invokable.canAccess(target)) return invokable; Method copy = invokable.getDeclaringClass().getDeclaredMethod(invokable.getName()); copy.setAccessible(true); return copy; }
Even placed as a private static in each file, it eliminates four copies of the comment. Optional.
-
Prefer
readMethod.getParameterTypes()over the implicit empty array —getDeclaredMethod(invokable.getName())works today because JavaBean getters are no-arg, but the call is subtly ambiguous to a reader who doesn't know that invariant.getDeclaredMethod(invokable.getName(), invokable.getParameterTypes())reads as "get the exact same method" and is trivially more defensive. -
Leak-detection test only covers the standalone-Java path —
JavaPersonFactory.standalonePerson('user')is the only shape exercised. The Groovy standalone path (PackagePrivateStandaloneGroovyBean) goes through the same widening but its spec doesn't assert the same invariant. Both marshallers copy the same pattern, so this is defensible, but a matching Groovy-side assertion would prove both marshallers preserve the invariant symmetrically. -
NoSuchMethodExceptionpropagation — the newgetDeclaredMethodcall adds a checked exception into the try body. Fine because the outercatch (Exception e)wraps it asConverterException. A one-liner comment noting "cannot fail:invokablecame from this class's declared methods" would help future readers who wonder whether this is a real failure surface. -
Nit — variable name —
invokableMethodreads oddly;invokableor justmethodwould be cleaner.
Test coverage
Strong. Coverage matrix:
| Shape | Groovy static | Groovy dynamic | Java |
|---|---|---|---|
| Anonymous impl of public interface | ✅ | ✅ | ✅ |
| Anonymous with public field | ✅ | — | — |
| Package-private impl of public interface | ✅ | — | ✅ |
| Package-private standalone (no interface) | ✅ | — | ✅ |
| Serializable + static fields | — | — | ✅ |
| Composite map (reproducer) | ✅ | — | — |
| Shared descriptor cache not mutated | — | — | ✅ |
Optional additions: a Java anonymous class with a public field (to lock in the intentional asymmetry, if kept), a Groovy-side cache-non-mutation assertion, and a Serializable Groovy bean.
Risk
Low. Changes are localized to four small marshallers. On the happy path (public class, public read method) the only added cost is one ClassUtils.getInterfaceMethodIfPossible cache lookup and one canAccess check — both cheap. The widening branch runs only when access is actually denied, and pays exactly one getDeclaredMethod per property in that case. The synthetic-field filter is defensive and can only remove keys that were themselves produced by the widened field access.
Verdict
LGTM. Well-scoped fix with a thorough commit message and matching test coverage. The Java-vs-Groovy field-access asymmetry (point 1) is the only thing I'd want a decision on before merge — either accept it as intentional and add a Java-side test to lock it in, or align by widening in the Java GenericJavaBeanMarshaller too. The other suggestions are polish.
|
@jdaugherty and @bkoehm do you want to weigh in on @matrei's bullet 1? |
|
@sbglasius My original concern was we should force these to be made public (as in change your code, not the framework) and not do this PR. Isn't that what bullet 1 is effectively identifying - that this is already the behavior of java based code? |
If we're talking about the AI-generated "Interface-method resolution" bullet, I can't make much of it. But I'll say, I'm nearly ready to approve this, but with a couple points. One, by approving, I'm not looking to circumvent any opinions of @jdaugherty . Two, I would like to know if you (@sbglasius) had any opinion on my response in this comment: |
Three things came out of review. The block that widens a read method was copy-pasted in all four marshallers. It now lives in `org.apache.grails.common.reflect.ReflectionUtils`, in grails-common, which is already on the compile classpath of every module that could need it. The GORM `ReflectionUtils` in grails-datastore-core is left alone: it is published, public since 1.0, and has eight call sites, so moving it is a separate change tracked on its own issue. Each marshaller now imports one utility rather than two Spring ones, and the method lookup asks for the exact parameter types. Java and Groovy beans now behave alike. The Java marshallers gated their field loop on `field.canAccess(o)`, so a public field on a non-public class was silently dropped where the Groovy ones emitted it. Both now widen a field copy, which `getDeclaredFields` hands out fresh on every call, so nothing leaks. A field that cannot be widened at all -- a class in a named module that does not open its package -- is skipped rather than failing the conversion, which is what the Java marshallers did before and what the Groovy ones did not. Widening access is compatibility handling rather than a contract, so every non-public bean class is reported once, through its own logger so a single logging line silences it. Bookkeeping runs before the log level is consulted, so "once" does not depend on how logging is configured, and the set holds class names rather than Class references so no class loader is retained. Classes from the JDK, Groovy and Spring are not reported, since nobody reading the log can declare them public. The upgrade guide gains a section: the field change alters response payloads, and the warning needs a documented way to turn it off. Raised in review of #16296.
|
@jdaugherty Pushed
On forcing the fix instead of handling it: for the shape in the original report there's no modifier to add — an anonymous class can't be declared public — which is what the warning is for, pointing people at the named-class fix. |
✅ All tests passed ✅🏷️ Commit: e9932f3 Learn more about TestLens at testlens.app/docs. |
jdaugherty
left a comment
There was a problem hiding this comment.
I like the direction and I think the warning is an acceptable solution. There's some minor improvements then we can merge ...
|
|
||
| ==== 54. Non-Public Bean Classes Are Marshalled, and Reported Once | ||
|
|
||
| `someObject as JSON` (and `as XML`) previously failed when the object's class was not `public` — an |
There was a problem hiding this comment.
This paragraph reads like it was broken since upgrading to Grails 7 - but it's only broken as of upgrading to Groovy 5. We should call out the Groovy 5 change that made this error, and explain that Grails adds this workaround (with a warning to update your code).
| return false; | ||
| } | ||
| if (LOG.isWarnEnabled()) { | ||
| LOG.warn("Class [{}] is not public, so its properties can only be read by widening access reflectively. " + |
There was a problem hiding this comment.
The point of this warning is to get people to change their code so the class is public, so the text has to be accurate about why. As written it says the properties "can only be read by widening access reflectively", which is not true for the shape that opened #16294: the anonymous UserDetails resolves to the interface method in resolveInvokableReadMethod and is invoked like any other public method, nothing is widened. Since the trigger is the class shape rather than the mechanism, describe the shape:
Class [{}] is not public. Grails reads its properties through compatibility handling that may be withdrawn in a future major release. Declare it as a named public class so that it reads as a standard JavaBean, or register an ObjectMarshaller for it if the class is not yours. To silence this, set the log level of [{}] above WARN. (warned once per class)
The ObjectMarshaller clause matters because NON_APPLICATION_PACKAGES is a short list; a non-public bean from any other library gets reported to someone who cannot declare it public. The upgrade guide quotes this message verbatim in 54.2, so it needs the same edit.
| } | ||
|
|
||
| private static Method readMethodOf(Object bean, String property) { | ||
| PropertyDescriptor descriptor = Introspector.getBeanInfo(bean.getClass()).propertyDescriptors |
There was a problem hiding this comment.
java.beans.Introspector already resolves a read method to its publicly accessible declaration, so two of these features never reach the branch they are named for. I compiled the fixtures and compared what each introspector hands back:
| Fixture | Introspector read method |
BeanUtils.getPropertyDescriptors read method |
|---|---|---|
anonymousThing |
PublicThing.getName, accessible |
the anonymous class, not accessible |
covariantThing |
PublicCovariantBase.getValue, accessible |
CovariantThing.getValue, not accessible |
standaloneThing |
StandaloneThing.getName, not accessible |
same |
So in "declared by a public interface needs no widening" and "covariant read method resolves to the override rather than the bridge", resolveInvokableReadMethod returns its argument unchanged and both would pass with return readMethod. Only the standalone feature exercises widening, and nothing exercises the getDeclaredMethod override-vs-bridge selection at all, because the covariant shape is not in the converters specs either.
Please take the read methods from BeanUtils.getPropertyDescriptors here, which is what the marshallers use and which returns the declaring-class method (spring-beans is already on this module's classpath through spring-context), and add the covariant shape to one of the marshaller specs so the bridge selection is covered end to end. Codecov's 72% on this class lines up with the two vacuous features.
| */ | ||
| public static Method resolveInvokableReadMethod(Method readMethod, Class<?> targetClass, @Nullable Object target) | ||
| throws NoSuchMethodException { | ||
| Method invokable = ClassUtils.getInterfaceMethodIfPossible(readMethod, targetClass); |
There was a problem hiding this comment.
ClassUtils.getPubliclyAccessibleMethodIfPossible(readMethod, targetClass) is in the spring-core we pin and does this plus the superclass walk: it returns the first equivalent method declared on a public type, interface or class. For CovariantThing (package-private, extends the public PublicCovariantBase) the interface-only resolver finds nothing and this method widens a copy; the publicly-accessible one returns PublicCovariantBase.getValue, canAccess is true, and invoke still dispatches to the override. I checked this with a probe against spring-core 7.0.9.
That confines the setAccessible path to getters with no public declaring type anywhere in the hierarchy, which is where the non-standard handling belongs, and it makes the override-vs-bridge getDeclaredMethod below rarer.
| // getDeclaredMethod cannot fail here: invokable was resolved from this very class's methods. | ||
| Method widened = invokable.getDeclaringClass() | ||
| .getDeclaredMethod(invokable.getName(), invokable.getParameterTypes()); | ||
| widened.setAccessible(true); |
There was a problem hiding this comment.
tryMakeReadable below catches InaccessibleObjectException and skips the field, but this setAccessible is left to throw, so a getter on a non-public class in a named module that does not open its package still fails the whole conversion (the marshallers wrap it as ConverterException, the same as before this PR). Section 54.1 of the upgrade guide says fields in that situation are skipped rather than failing the conversion, which reads as if the conversion survives; with a getter in the same module it will not. Either skip the property here the same way, or narrow that sentence in the guide. Low priority since it is not a regression.
Fixes #16294
Fixes #16295
someObject as JSON(andas XML) failed for two very common shapes of object. Both are fixed here, since fixing the first one only exposes the second.#16294 —
IllegalAccessExceptionon a non-public classA class that is not public — anonymous, local or package-private — cannot have its read methods invoked reflectively from another package, even though the methods themselves carry the
publicmodifier. All four bean marshallers calledreadMethod.invoke(...)with no accessibility handling, so handingas JSONan anonymous implementation of a public interface (the usual shape of a Spring SecurityUserDetails) failed with:The read method is now resolved to the interface method where one exists, and made accessible otherwise:
Resolving to the interface is not sufficient on its own — a package-private class with a public getter and no interface has nowhere to resolve to — so
makeAccessiblecarries that case.The public field loops of both
GroovyBeanMarshallers failed identically onfield.get(o)and are now made accessible too.#16295 —
IllegalArgumentExceptionon anySerializablebeanGenericJavaBeanMarshallerevaluatedfield.canAccess(o)before the static check, and per its javadoccanAccessthrows for a static member when the object is non-null. Any bean declaringprivate static final long serialVersionUID— nearly everySerializablebean — therefore failed:The modifier checks now run first so they short-circuit before
canAccess. This is a regression in the 8.x line from9e60b8a4de, which mechanically swapped the non-throwingisAccessible()forcanAccess(o).One change beyond the two issues
Groovy compiles the variables captured by an anonymous class into
ACC_PUBLIC | ACC_SYNTHETICgroovy.lang.Referencefields. Once the field loops could actually read them, they were emitted as duplicate keys with empty-object values ({"name":{},"age":{}}) — the new tests caught exactly that. Synthetic fields are compiler artifacts and never part of a bean's state, so they are now skipped in all four marshallers.Files changed
grails-converters/.../marshaller/json/GroovyBeanMarshaller.javagrails-converters/.../marshaller/json/GenericJavaBeanMarshaller.javagrails-converters/.../marshaller/xml/GroovyBeanMarshaller.javagrails-converters/.../marshaller/xml/GenericJavaBeanMarshaller.javaTests
New fixtures under
org.grails.web.converters.beans, deliberately in a different package from the marshallers — in the same package the JVM access check passes and neither bug reproduces. They cover a public interface with both abstract anddefaultread methods (mirroringUserDetails), Groovy and Java anonymous / package-private / no-interface implementations, an anonymous class with a declared public field, and aSerializablebean carryingprivate static final long serialVersionUIDalongside a public constant and a public instance field.Two new specs of 9 features each, driven through the public
new JSON(x)/new XML(x)API:json.NonPublicClassMarshallingSpecandxml.NonPublicClassMarshallingSpec. Every non-public case first asserts!Modifier.isPublic(person.getClass().modifiers), so the tests fail loudly rather than silently passing if a future compiler stops producing a non-public class.With the production change reverted, 18 of 18 fail with the two reported exceptions verbatim; with it, 18/18 pass. Also green locally: full
:grails-converters:test,:grails-rest-transforms:test,:grails-test-suite-web:test,:grails-test-suite-uber:test,:grails-web-common:testand:grails-converters:codeStyle.No documentation change: this restores the documented behaviour of
as JSON/as XMLand adds no public API.