This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 9c4df0d6b5 NestedCopyWithSupport: report the closed-domain error for a
Class-valued node
9c4df0d6b5 is described below
commit 9c4df0d6b513ac411bab14fd712b91c158471cc8
Author: Paul King <[email protected]>
AuthorDate: Mon Sep 7 16:14:06 2026 +1000
NestedCopyWithSupport: report the closed-domain error for a Class-valued
node
A 'class' head in a nested copyWith path (e.g.
copyWith('address.class.name': x))
resolves to a java.lang.Class, which spuriously responds to copyWith via
static-method dispatch, so the guard passed and the call failed later with
a raw
MissingMethodException instead of the intended "outside the supported
nested-copyWith domain" message. Exclude Class values from the guard so the
clean closed-domain error is reported. The operation already failed safely;
this
only improves the message. Also record the feature's confinement in
THREAT_MODEL.md.
---
THREAT_MODEL.md | 1 +
.../groovy/transform/copywith/NestedCopyWithSupport.java | 6 +++++-
.../groovy/transform/ImmutableNestedCopyWithTest.groovy | 14 ++++++++++++++
3 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index cf95ee9374..dbe35a60ab 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -705,6 +705,7 @@ vulnerabilities** unless a concrete, in-model data-boundary
crossing
| `@Grab`/Grape fetching and loading artifacts | By design dependency
resolution — `OUT-OF-MODEL` |
| Dependency-verification config that trusts by checksum or by group rather
than per-artifact signature — `verify-metadata=false`, group-scoped `<trust>`
entries, or an `ignored-key` with a checksum fallback
(`gradle/verification-metadata.xml`) | Deliberate, scoped dial-downs, each
recorded in-file: metadata enforcement is off while jar integrity still holds
via `verify-signatures` + checksums; self-resolved baselines
(`org.apache.groovy`, `org.codehaus.groovy`) and the doc toolchain (`o [...]
| A CI canary/test job downloading a pre-release toolchain (the `ea` OpenJDK
job in `.github/workflows/groovy-build-test.yml`) | Push-only (`if:
github.event_name == 'push'` — no fork/PR path), read-only `GITHUB_TOKEN`, and
test-only: the EA JDK runs the suite but never builds or signs a release
artifact (Gradle itself runs on a GA JDK), and the tarball is checksum-pinned.
`KNOWN-NON-FINDING` unless the job gains write/publish permissions or a release
build consumes the pre-release toolchain |
+| Nested-path `copyWith` navigating an object graph by dotted map keys
(`x.copyWith('a.b': v)`, `NestedCopyWithSupport`) | The keys are
developer-supplied property paths; navigation is confined to the
`@Immutable`/`@RecordType` graph — each hop must expose `copyWith(Map)` or a
clear closed-domain error is thrown, so it cannot escape into
`.class`/`.metaClass`. It reads declared properties only, invokes the fixed
`copyWith` (never an attacker-named method), mutates nothing, and delegates
[...]
| Temp-file/dir creation, and other artifacts tooling writes | Owner-only or
least-exposure (P4); path-contained (P4b) — `KNOWN-NON-FINDING` unless a
*default-config* case widens exposure or escapes its tree, which is
`VALID-HARDENING` |
| Regex, `BigInteger`/`BigDecimal` parsing, hash-collision flooding (JDK
treeifies heavily-collided `String`-keyed buckets since Java 8) | DoS bounded
by developer-chosen input — `OUT-OF-MODEL: downstream-responsibility` |
| Deep recursion / unbounded input in Groovy's *own* data parsers
(`JsonSlurper`, `XmlSlurper`/`XmlParser`, `groovy-yaml`/`-toml`/`-csv`) |
Robustness of code meant to consume untrusted input — **`VALID-HARDENING`**
*(maintainer)*; nesting depth is now bounded by default in all of them (JSON
via the 6.0.0 `maxNestingDepth` cap, GROOVY-12064; XML via the 6.0.0
`jdk.xml.maxElementDepth` bound, GROOVY-12331), per-parser exposure in
[§6](#6-assumptions-about-inputs) |
diff --git
a/src/main/java/org/apache/groovy/transform/copywith/NestedCopyWithSupport.java
b/src/main/java/org/apache/groovy/transform/copywith/NestedCopyWithSupport.java
index 5ff32da555..a18df57f2b 100644
---
a/src/main/java/org/apache/groovy/transform/copywith/NestedCopyWithSupport.java
+++
b/src/main/java/org/apache/groovy/transform/copywith/NestedCopyWithSupport.java
@@ -99,7 +99,11 @@ public final class NestedCopyWithSupport {
// A nested node must itself expose copyWith(Map); fail clearly
otherwise.
// Probe with the actual nested map so a type that only has
// copyWith()/copyWith(Closure) does not falsely pass this guard.
- boolean supported = !InvokerHelper.getMetaClass(current)
+ // A Class value (e.g. from a 'class' head) can spuriously respond
to
+ // copyWith via static-method dispatch, so exclude it and let the
+ // closed-domain error below report it cleanly.
+ boolean supported = !(current instanceof Class)
+ && !InvokerHelper.getMetaClass(current)
.respondsTo(current, "copyWith", new
Object[]{e.getValue()}).isEmpty();
if (!supported) {
throw new GroovyRuntimeException("copyWith: nested update of
'" + head
diff --git
a/src/test/groovy/org/codehaus/groovy/transform/ImmutableNestedCopyWithTest.groovy
b/src/test/groovy/org/codehaus/groovy/transform/ImmutableNestedCopyWithTest.groovy
index 1710a3cd40..0c7996117c 100644
---
a/src/test/groovy/org/codehaus/groovy/transform/ImmutableNestedCopyWithTest.groovy
+++
b/src/test/groovy/org/codehaus/groovy/transform/ImmutableNestedCopyWithTest.groovy
@@ -111,6 +111,20 @@ final class ImmutableNestedCopyWithTest {
assert err.message.contains('nested-copyWith domain')
}
+ @Test
+ void navigating_into_a_class_property_fails_with_the_domain_error() {
+ // a 'class' head resolves to a java.lang.Class, which can spuriously
respond to
+ // copyWith via static dispatch; it must report the clean
closed-domain error
+ def err = shouldFail shell, '''
+ @Immutable(copyWith = true) class Address { String city }
+ @Immutable(copyWith = true) class Person { String name; Address
address }
+ def p = new Person('Alice', new Address('NYC'))
+ p.copyWith('address.class.name': 'x')
+ '''
+ assert err.message.contains('nested-copyWith domain')
+ assert err.message.contains('java.lang.Class')
+ }
+
@Test
void null_intermediate_node_fails_clearly() {
def err = shouldFail shell, '''