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, '''

Reply via email to