diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index d59702cae..85aea5501 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -15,7 +15,7 @@ jobs: name: Java 1.6 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup java uses: actions/setup-java@v1 with: @@ -30,7 +30,7 @@ jobs: jar cvf target/org.json.jar -C target/classes . - name: Upload JAR 1.6 if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Create java 1.6 JAR path: target/*.jar @@ -45,9 +45,9 @@ jobs: java: [ 8 ] name: Java ${{ matrix.java }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: ${{ matrix.java }} @@ -64,13 +64,13 @@ jobs: mvn site -D generateReports=false -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} - name: Upload Test Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Results ${{ matrix.java }} path: target/surefire-reports/ - name: Upload Test Report ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Report ${{ matrix.java }} path: target/site/ @@ -78,7 +78,7 @@ jobs: run: mvn clean package -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} -D maven.test.skip=true -D maven.site.skip=true - name: Upload Package Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Package Jar ${{ matrix.java }} path: target/*.jar @@ -93,9 +93,9 @@ jobs: java: [ 11 ] name: Java ${{ matrix.java }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: ${{ matrix.java }} @@ -112,13 +112,13 @@ jobs: mvn site -D generateReports=false -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} - name: Upload Test Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Results ${{ matrix.java }} path: target/surefire-reports/ - name: Upload Test Report ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Report ${{ matrix.java }} path: target/site/ @@ -126,7 +126,7 @@ jobs: run: mvn clean package -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} -D maven.test.skip=true -D maven.site.skip=true - name: Upload Package Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Package Jar ${{ matrix.java }} path: target/*.jar @@ -141,9 +141,9 @@ jobs: java: [ 17 ] name: Java ${{ matrix.java }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: ${{ matrix.java }} @@ -160,13 +160,13 @@ jobs: mvn site -D generateReports=false -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} - name: Upload Test Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Results ${{ matrix.java }} path: target/surefire-reports/ - name: Upload Test Report ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Report ${{ matrix.java }} path: target/site/ @@ -174,7 +174,7 @@ jobs: run: mvn clean package -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} -D maven.test.skip=true -D maven.site.skip=true - name: Upload Package Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Package Jar ${{ matrix.java }} path: target/*.jar @@ -189,9 +189,9 @@ jobs: java: [ 21 ] name: Java ${{ matrix.java }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: ${{ matrix.java }} @@ -208,13 +208,13 @@ jobs: mvn site -D generateReports=false -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} - name: Upload Test Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Results ${{ matrix.java }} path: target/surefire-reports/ - name: Upload Test Report ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Test Report ${{ matrix.java }} path: target/site/ @@ -222,7 +222,56 @@ jobs: run: mvn clean package -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} -D maven.test.skip=true -D maven.site.skip=true - name: Upload Package Results ${{ matrix.java }} if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: Package Jar ${{ matrix.java }} path: target/*.jar + + build-25: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 1 + matrix: + # build against supported Java LTS versions: + java: [ 25 ] + name: Java ${{ matrix.java }} + steps: + - uses: actions/checkout@v5 + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: ${{ matrix.java }} + cache: 'maven' + - name: Compile Java ${{ matrix.java }} + run: mvn clean compile -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} -D maven.test.skip=true -D maven.site.skip=true -D maven.javadoc.skip=true + - name: Run Tests ${{ matrix.java }} + run: | + mvn test -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} + - name: Build Test Report ${{ matrix.java }} + if: ${{ always() }} + run: | + mvn surefire-report:report-only -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} + mvn site -D generateReports=false -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} + - name: Upload Test Results ${{ matrix.java }} + if: ${{ always() }} + uses: actions/upload-artifact@v5 + with: + name: Test Results ${{ matrix.java }} + path: target/surefire-reports/ + - name: Upload Test Report ${{ matrix.java }} + if: ${{ always() }} + uses: actions/upload-artifact@v5 + with: + name: Test Report ${{ matrix.java }} + path: target/site/ + - name: Package Jar ${{ matrix.java }} + run: mvn clean package -D maven.compiler.source=${{ matrix.java }} -D maven.compiler.target=${{ matrix.java }} -D maven.test.skip=true -D maven.site.skip=true + - name: Upload Package Results ${{ matrix.java }} + if: ${{ always() }} + uses: actions/upload-artifact@v5 + with: + name: Package Jar ${{ matrix.java }} + path: target/*.jar + diff --git a/.gitignore b/.gitignore index b78af4db7..0e08d645c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ build /gradlew /gradlew.bat .gitmodules + +# ignore compiled class files +*.class diff --git a/README.md b/README.md index 28f71971e..47465b134 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ JSON in Java [package org.json] [![Maven Central](https://img.shields.io/maven-central/v/org.json/json.svg)](https://mvnrepository.com/artifact/org.json/json) [![Java CI with Maven](https://github.com/stleary/JSON-java/actions/workflows/pipeline.yml/badge.svg)](https://github.com/stleary/JSON-java/actions/workflows/pipeline.yml) [![CodeQL](https://github.com/stleary/JSON-java/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/stleary/JSON-java/actions/workflows/codeql-analysis.yml) +[![javadoc](https://javadoc.io/badge2/org.json/json/javadoc.svg)](https://javadoc.io/doc/org.json/json) -**[Click here if you just want the latest release jar file.](https://search.maven.org/remotecontent?filepath=org/json/json/20250517/json-20250517.jar)** +**[Click here if you just want the latest release jar file.](https://search.maven.org/remotecontent?filepath=org/json/json/20251224/json-20251224.jar)** # Overview @@ -19,6 +20,8 @@ JSON in Java [package org.json] The JSON-Java package is a reference implementation that demonstrates how to parse JSON documents into Java objects and how to generate new JSON documents from the Java classes. +The files in this package implement JSON encoders and decoders. The package can also convert between JSON and XML, HTTP headers, Cookies, and CDL. + Project goals include: * Reliable and consistent results * Adherence to the JSON specification @@ -26,10 +29,18 @@ Project goals include: * No external dependencies * Fast execution and low memory footprint * Maintain backward compatibility -* Designed and tested to use on Java versions 1.6 - 21 +* Designed and tested to use on Java versions 1.6 - 25 +# License Clarification +This project is in the public domain. This means: +* You can use this code for any purpose, including commercial projects +* No attribution or credit is required +* You can modify, distribute, and sublicense freely +* There are no conditions or restrictions whatsoever + +We recognize this can create uncertainty for some corporate legal departments accustomed to standard licenses like MIT or Apache 2.0. +If your organization requires a named license for compliance purposes, public domain is functionally equivalent to the Unlicense or CC0 1.0, both of which have been reviewed and accepted by organizations including the Open Source Initiative and Creative Commons. You may reference either when explaining this project's terms to your legal team. -The files in this package implement JSON encoders and decoders. The package can also convert between JSON and XML, HTTP headers, Cookies, and CDL. # If you would like to contribute to this project diff --git a/build.gradle b/build.gradle index 6dcdca6fc..898f10dc7 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ subprojects { } group = 'org.json' -version = 'v20250517-SNAPSHOT' +version = 'v20251224-SNAPSHOT' description = 'JSON in Java' sourceCompatibility = '1.8' diff --git a/docs/RELEASES.md b/docs/RELEASES.md index cd53bbe55..653e2bb8c 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -5,6 +5,8 @@ and artifactId "json". For example: [https://search.maven.org/search?q=g:org.json%20AND%20a:json&core=gav](https://search.maven.org/search?q=g:org.json%20AND%20a:json&core=gav) ~~~ +20251224 Records, fromJson(), and recent commits + 20250517 Strict mode hardening and recent commits 20250107 Restore moditect in pom.xml diff --git a/pom.xml b/pom.xml index 81f5c3c2c..8d0881cbe 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ org.json json - 20250517 + 20251224 bundle JSON in Java diff --git a/src/main/java/org/json/JSONML.java b/src/main/java/org/json/JSONML.java index bde97a680..6ec997061 100644 --- a/src/main/java/org/json/JSONML.java +++ b/src/main/java/org/json/JSONML.java @@ -22,6 +22,33 @@ public class JSONML { public JSONML() { } + /** + * Safely cast parse result to JSONArray with proper type checking. + * @param result The result from parse() method + * @return JSONArray if result is a JSONArray + * @throws JSONException if result is not a JSONArray + */ + private static JSONArray toJSONArraySafe(Object result) throws JSONException { + if (result instanceof JSONArray) { + return (JSONArray) result; + } + throw new JSONException("Expected JSONArray but got " + + (result == null ? "null" : result.getClass().getSimpleName())); + } + + /** + * Safely cast parse result to JSONObject with proper type checking. + * @param result The result from parse() method + * @return JSONObject if result is a JSONObject + * @throws JSONException if result is not a JSONObject + */ + private static JSONObject toJSONObjectSafe(Object result) throws JSONException { + if (result instanceof JSONObject) { + return (JSONObject) result; + } + throw new JSONException("Expected JSONObject but got " + + (result == null ? "null" : result.getClass().getSimpleName())); + } /** * Parse XML values and store them in a JSONArray. @@ -276,7 +303,7 @@ private static Object parse( * @throws JSONException Thrown on error converting to a JSONArray */ public static JSONArray toJSONArray(String string) throws JSONException { - return (JSONArray)parse(new XMLTokener(string), true, null, JSONMLParserConfiguration.ORIGINAL, 0); + return toJSONArraySafe(parse(new XMLTokener(string), true, null, JSONMLParserConfiguration.ORIGINAL, 0)); } @@ -298,7 +325,7 @@ public static JSONArray toJSONArray(String string) throws JSONException { * @throws JSONException Thrown on error converting to a JSONArray */ public static JSONArray toJSONArray(String string, boolean keepStrings) throws JSONException { - return (JSONArray)parse(new XMLTokener(string), true, null, keepStrings, 0); + return toJSONArraySafe(parse(new XMLTokener(string), true, null, keepStrings, 0)); } @@ -323,7 +350,7 @@ public static JSONArray toJSONArray(String string, boolean keepStrings) throws J * @throws JSONException Thrown on error converting to a JSONArray */ public static JSONArray toJSONArray(String string, JSONMLParserConfiguration config) throws JSONException { - return (JSONArray)parse(new XMLTokener(string), true, null, config, 0); + return toJSONArraySafe(parse(new XMLTokener(string), true, null, config, 0)); } @@ -347,7 +374,7 @@ public static JSONArray toJSONArray(String string, JSONMLParserConfiguration con * @throws JSONException Thrown on error converting to a JSONArray */ public static JSONArray toJSONArray(XMLTokener x, JSONMLParserConfiguration config) throws JSONException { - return (JSONArray)parse(x, true, null, config, 0); + return toJSONArraySafe(parse(x, true, null, config, 0)); } @@ -369,7 +396,7 @@ public static JSONArray toJSONArray(XMLTokener x, JSONMLParserConfiguration conf * @throws JSONException Thrown on error converting to a JSONArray */ public static JSONArray toJSONArray(XMLTokener x, boolean keepStrings) throws JSONException { - return (JSONArray)parse(x, true, null, keepStrings, 0); + return toJSONArraySafe(parse(x, true, null, keepStrings, 0)); } @@ -386,7 +413,7 @@ public static JSONArray toJSONArray(XMLTokener x, boolean keepStrings) throws JS * @throws JSONException Thrown on error converting to a JSONArray */ public static JSONArray toJSONArray(XMLTokener x) throws JSONException { - return (JSONArray)parse(x, true, null, false, 0); + return toJSONArraySafe(parse(x, true, null, false, 0)); } @@ -404,7 +431,7 @@ public static JSONArray toJSONArray(XMLTokener x) throws JSONException { * @throws JSONException Thrown on error converting to a JSONObject */ public static JSONObject toJSONObject(String string) throws JSONException { - return (JSONObject)parse(new XMLTokener(string), false, null, false, 0); + return toJSONObjectSafe(parse(new XMLTokener(string), false, null, false, 0)); } @@ -424,7 +451,7 @@ public static JSONObject toJSONObject(String string) throws JSONException { * @throws JSONException Thrown on error converting to a JSONObject */ public static JSONObject toJSONObject(String string, boolean keepStrings) throws JSONException { - return (JSONObject)parse(new XMLTokener(string), false, null, keepStrings, 0); + return toJSONObjectSafe(parse(new XMLTokener(string), false, null, keepStrings, 0)); } @@ -446,7 +473,7 @@ public static JSONObject toJSONObject(String string, boolean keepStrings) throws * @throws JSONException Thrown on error converting to a JSONObject */ public static JSONObject toJSONObject(String string, JSONMLParserConfiguration config) throws JSONException { - return (JSONObject)parse(new XMLTokener(string), false, null, config, 0); + return toJSONObjectSafe(parse(new XMLTokener(string), false, null, config, 0)); } @@ -464,7 +491,7 @@ public static JSONObject toJSONObject(String string, JSONMLParserConfiguration c * @throws JSONException Thrown on error converting to a JSONObject */ public static JSONObject toJSONObject(XMLTokener x) throws JSONException { - return (JSONObject)parse(x, false, null, false, 0); + return toJSONObjectSafe(parse(x, false, null, false, 0)); } @@ -484,7 +511,7 @@ public static JSONObject toJSONObject(XMLTokener x) throws JSONException { * @throws JSONException Thrown on error converting to a JSONObject */ public static JSONObject toJSONObject(XMLTokener x, boolean keepStrings) throws JSONException { - return (JSONObject)parse(x, false, null, keepStrings, 0); + return toJSONObjectSafe(parse(x, false, null, keepStrings, 0)); } @@ -506,7 +533,7 @@ public static JSONObject toJSONObject(XMLTokener x, boolean keepStrings) throws * @throws JSONException Thrown on error converting to a JSONObject */ public static JSONObject toJSONObject(XMLTokener x, JSONMLParserConfiguration config) throws JSONException { - return (JSONObject)parse(x, false, null, config, 0); + return toJSONObjectSafe(parse(x, false, null, config, 0)); } diff --git a/src/main/java/org/json/JSONObject.java b/src/main/java/org/json/JSONObject.java index 4e8b42c97..db2c2aac7 100644 --- a/src/main/java/org/json/JSONObject.java +++ b/src/main/java/org/json/JSONObject.java @@ -144,6 +144,18 @@ public Class getMapType() { */ public static final Object NULL = new Null(); + /** + * Set of method names that should be excluded when identifying record-style accessors. + * These are common bean/Object method names that are not property accessors. + */ + private static final Set EXCLUDED_RECORD_METHOD_NAMES = Collections.unmodifiableSet( + new HashSet(Arrays.asList( + "get", "is", "set", + "toString", "hashCode", "equals", "clone", + "notify", "notifyAll", "wait" + )) + ); + /** * Construct an empty JSONObject. */ @@ -1823,11 +1835,14 @@ private void populateMap(Object bean, Set objectsRecord, JSONParserConfi Class klass = bean.getClass(); // If klass is a System class then set includeSuperClass to false. + + // Check if this is a Java record type + boolean isRecord = isRecordType(klass); Method[] methods = getMethods(klass); for (final Method method : methods) { if (isValidMethod(method)) { - final String key = getKeyNameFromMethod(method); + final String key = getKeyNameFromMethod(method, isRecord); if (key != null && !key.isEmpty()) { processMethod(bean, objectsRecord, jsonParserConfiguration, method, key); } @@ -1873,6 +1888,29 @@ private void processMethod(Object bean, Set objectsRecord, JSONParserCon } } + /** + * Checks if a class is a Java record type. + * This uses reflection to check for the isRecord() method which was introduced in Java 16. + * This approach works even when running on Java 6+ JVM. + * + * @param klass the class to check + * @return true if the class is a record type, false otherwise + */ + private static boolean isRecordType(Class klass) { + try { + // Use reflection to check if Class has an isRecord() method (Java 16+) + // This allows the code to compile on Java 6 while still detecting records at runtime + Method isRecordMethod = Class.class.getMethod("isRecord"); + return (Boolean) isRecordMethod.invoke(klass); + } catch (NoSuchMethodException e) { + // isRecord() method doesn't exist - we're on Java < 16 + return false; + } catch (Exception e) { + // Any other reflection error - assume not a record + return false; + } + } + /** * This is a convenience method to simplify populate maps * @param klass the name of the object being checked @@ -1885,10 +1923,11 @@ private static Method[] getMethods(Class klass) { } private static boolean isValidMethodName(String name) { - return !"getClass".equals(name) && !"getDeclaringClass".equals(name); + return !"getClass".equals(name) + && !"getDeclaringClass".equals(name); } - private static String getKeyNameFromMethod(Method method) { + private static String getKeyNameFromMethod(Method method, boolean isRecordType) { final int ignoreDepth = getAnnotationDepth(method, JSONPropertyIgnore.class); if (ignoreDepth > 0) { final int forcedNameDepth = getAnnotationDepth(method, JSONPropertyName.class); @@ -1909,6 +1948,11 @@ private static String getKeyNameFromMethod(Method method) { } else if (name.startsWith("is") && name.length() > 2) { key = name.substring(2); } else { + // Only check for record-style accessors if this is actually a record type + // This maintains backward compatibility - classes with lowercase methods won't be affected + if (isRecordType && isRecordStyleAccessor(name, method)) { + return name; + } return null; } // if the first letter in the key is not uppercase, then skip. @@ -1925,6 +1969,37 @@ private static String getKeyNameFromMethod(Method method) { return key; } + /** + * Checks if a method is a record-style accessor. + * Record accessors have lowercase names without get/is prefixes and are not inherited from standard Java classes. + * + * @param methodName the name of the method + * @param method the method to check + * @return true if this is a record-style accessor, false otherwise + */ + private static boolean isRecordStyleAccessor(String methodName, Method method) { + if (methodName.isEmpty() || !Character.isLowerCase(methodName.charAt(0))) { + return false; + } + + // Exclude common bean/Object method names + if (EXCLUDED_RECORD_METHOD_NAMES.contains(methodName)) { + return false; + } + + Class declaringClass = method.getDeclaringClass(); + if (declaringClass == null || declaringClass == Object.class) { + return false; + } + + if (Enum.class.isAssignableFrom(declaringClass) || Number.class.isAssignableFrom(declaringClass)) { + return false; + } + + String className = declaringClass.getName(); + return !className.startsWith("java.") && !className.startsWith("javax."); + } + /** * checks if the annotation is not null and the {@link JSONPropertyName#value()} is not null and is not empty. * @param annotation the annotation to check diff --git a/src/main/java/org/json/XML.java b/src/main/java/org/json/XML.java index 3eb948c77..7e4b0bb0c 100644 --- a/src/main/java/org/json/XML.java +++ b/src/main/java/org/json/XML.java @@ -9,6 +9,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.util.Iterator; +import java.util.NoSuchElementException; /** * This provides static methods to convert an XML text into a JSONObject, and to @@ -80,7 +81,7 @@ private static Iterable codePointIterator(final String string) { public Iterator iterator() { return new Iterator() { private int nextIndex = 0; - private int length = string.length(); + private final int length = string.length(); @Override public boolean hasNext() { @@ -89,6 +90,9 @@ public boolean hasNext() { @Override public Integer next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } int result = string.codePointAt(this.nextIndex); this.nextIndex += Character.charCount(result); return result; @@ -387,8 +391,13 @@ private static boolean parse(XMLTokener x, JSONObject context, String name, XMLP context.append(tagName, JSONObject.NULL); } else if (jsonObject.length() > 0) { context.append(tagName, jsonObject); - } else { + } else if(context.isEmpty()) { //avoids resetting the array in case of an empty tag in the middle or end context.put(tagName, new JSONArray()); + if (jsonObject.isEmpty()){ + context.append(tagName, ""); + } + } else { + context.append(tagName, ""); } } else { if (nilAttributeFound) { @@ -447,7 +456,11 @@ private static boolean parse(XMLTokener x, JSONObject context, String name, XMLP if (config.getForceList().contains(tagName)) { // Force the value to be an array if (jsonObject.length() == 0) { - context.put(tagName, new JSONArray()); + //avoids resetting the array in case of an empty element in the middle or end + if(context.isEmpty()) { + context.put(tagName, new JSONArray()); + } + context.append(tagName, ""); } else if (jsonObject.length() == 1 && jsonObject.opt(config.getcDataTagName()) != null) { context.append(tagName, jsonObject.opt(config.getcDataTagName())); diff --git a/src/main/java/org/json/XMLTokener.java b/src/main/java/org/json/XMLTokener.java index bc18b31c9..dad2e2897 100644 --- a/src/main/java/org/json/XMLTokener.java +++ b/src/main/java/org/json/XMLTokener.java @@ -151,33 +151,108 @@ public Object nextEntity(@SuppressWarnings("unused") char ampersand) throws JSON /** * Unescape an XML entity encoding; * @param e entity (only the actual entity value, not the preceding & or ending ; - * @return + * @return the unescaped entity string + * @throws JSONException if the entity is malformed */ - static String unescapeEntity(String e) { + static String unescapeEntity(String e) throws JSONException { // validate if (e == null || e.isEmpty()) { return ""; } // if our entity is an encoded unicode point, parse it. if (e.charAt(0) == '#') { - int cp; - if (e.charAt(1) == 'x' || e.charAt(1) == 'X') { - // hex encoded unicode - cp = Integer.parseInt(e.substring(2), 16); - } else { - // decimal encoded unicode - cp = Integer.parseInt(e.substring(1)); + if (e.length() < 2) { + throw new JSONException("Invalid numeric character reference: &#;"); } - return new String(new int[] {cp},0,1); - } + int cp = (e.charAt(1) == 'x' || e.charAt(1) == 'X') + ? parseHexEntity(e) + : parseDecimalEntity(e); + return new String(new int[] {cp}, 0, 1); + } Character knownEntity = entity.get(e); - if(knownEntity==null) { + if (knownEntity == null) { // we don't know the entity so keep it encoded return '&' + e + ';'; } return knownEntity.toString(); } + /** + * Parse a hexadecimal numeric character reference (e.g., "઼"). + * @param e entity string starting with '#' (e.g., "#x1F4A9") + * @return the Unicode code point + * @throws JSONException if the format is invalid + */ + private static int parseHexEntity(String e) throws JSONException { + // hex encoded unicode - need at least one hex digit after #x + if (e.length() < 3) { + throw new JSONException("Invalid hex character reference: missing hex digits in &#" + e.substring(1) + ";"); + } + String hex = e.substring(2); + if (!isValidHex(hex)) { + throw new JSONException("Invalid hex character reference: &#" + e.substring(1) + ";"); + } + try { + return Integer.parseInt(hex, 16); + } catch (NumberFormatException nfe) { + throw new JSONException("Invalid hex character reference: &#" + e.substring(1) + ";", nfe); + } + } + + /** + * Parse a decimal numeric character reference (e.g., "{"). + * @param e entity string starting with '#' (e.g., "#123") + * @return the Unicode code point + * @throws JSONException if the format is invalid + */ + private static int parseDecimalEntity(String e) throws JSONException { + String decimal = e.substring(1); + if (!isValidDecimal(decimal)) { + throw new JSONException("Invalid decimal character reference: &#" + decimal + ";"); + } + try { + return Integer.parseInt(decimal); + } catch (NumberFormatException nfe) { + throw new JSONException("Invalid decimal character reference: &#" + decimal + ";", nfe); + } + } + + /** + * Check if a string contains only valid hexadecimal digits. + * @param s the string to check + * @return true if s is non-empty and contains only hex digits (0-9, a-f, A-F) + */ + private static boolean isValidHex(String s) { + if (s == null || s.isEmpty()) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; + } + + /** + * Check if a string contains only valid decimal digits. + * @param s the string to check + * @return true if s is non-empty and contains only digits (0-9) + */ + private static boolean isValidDecimal(String s) { + if (s == null || s.isEmpty()) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + /** *
{@code 
diff --git a/src/test/java/org/json/junit/JSONMLTest.java b/src/test/java/org/json/junit/JSONMLTest.java
index 5a360dd59..93a6821d8 100644
--- a/src/test/java/org/json/junit/JSONMLTest.java
+++ b/src/test/java/org/json/junit/JSONMLTest.java
@@ -986,4 +986,70 @@ public void testToJSONObjectMaxNestingDepthWithValidFittingXML() {
         }
     }
 
+    /**
+     * Tests that malformed XML causing type mismatch throws JSONException.
+     * Previously threw ClassCastException when parse() returned String instead of JSONArray.
+     * Related to issue #1034
+     */
+    @Test(expected = JSONException.class)
+    public void testMalformedXMLThrowsJSONExceptionNotClassCast() {
+        // This malformed XML causes parse() to return wrong type
+        byte[] data = {0x3c, 0x0a, 0x2f, (byte)0xff, (byte)0xff, (byte)0xff,
+                      (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff,
+                      (byte)0xff, 0x3e, 0x42};
+        String xmlStr = new String(data);
+        JSONML.toJSONArray(xmlStr);
+    }
+
+    /**
+     * Tests that type mismatch in toJSONObject throws JSONException.
+     * Validates safe type casting in toJSONObject methods.
+     */
+    @Test
+    public void testToJSONObjectTypeMismatch() {
+        // Create XML that would cause parse() to return wrong type
+        String xmlStr = "<\n/\u00ff\u00ff\u00ff\u00ff\u00ff\u00ff\u00ff\u00ff>B";
+        try {
+            JSONML.toJSONObject(xmlStr);
+            fail("Expected JSONException for type mismatch");
+        } catch (ClassCastException e) {
+            fail("Should throw JSONException, not ClassCastException");
+        } catch (JSONException e) {
+            // Expected - verify it's about type mismatch
+            assertTrue("Exception message should mention type error",
+                e.getMessage().contains("Expected") || e.getMessage().contains("got"));
+        }
+    }
+
+    /**
+     * Tests that valid XML still works correctly after the fix.
+     * Ensures the type checking doesn't break normal operation.
+     */
+    @Test
+    public void testValidXMLStillWorks() {
+        String xmlStr = "value";
+        try {
+            JSONArray jsonArray = JSONML.toJSONArray(xmlStr);
+            assertNotNull("JSONArray should not be null", jsonArray);
+            assertEquals("root", jsonArray.getString(0));
+        } catch (Exception e) {
+            fail("Valid XML should not throw exception: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Tests that valid XML to JSONObject still works correctly.
+     */
+    @Test
+    public void testValidXMLToJSONObjectStillWorks() {
+        String xmlStr = "content";
+        try {
+            JSONObject jsonObject = JSONML.toJSONObject(xmlStr);
+            assertNotNull("JSONObject should not be null", jsonObject);
+            assertEquals("root", jsonObject.getString("tagName"));
+        } catch (Exception e) {
+            fail("Valid XML should not throw exception: " + e.getMessage());
+        }
+    }
+
 }
diff --git a/src/test/java/org/json/junit/JSONObjectLocaleTest.java b/src/test/java/org/json/junit/JSONObjectLocaleTest.java
index 1cdaf743d..e1a9dd64e 100755
--- a/src/test/java/org/json/junit/JSONObjectLocaleTest.java
+++ b/src/test/java/org/json/junit/JSONObjectLocaleTest.java
@@ -36,25 +36,31 @@ public void jsonObjectByLocaleBean() {
 
         MyLocaleBean myLocaleBean = new MyLocaleBean();
 
-        /**
-         * This is just the control case which happens when the locale.ROOT
-         * lowercasing behavior is the same as the current locale.
-         */
-        Locale.setDefault(new Locale("en"));
-        JSONObject jsonen = new JSONObject(myLocaleBean);
-        assertEquals("expected size 2, found: " +jsonen.length(), 2, jsonen.length());
-        assertEquals("expected jsonen[i] == beanI", "beanI", jsonen.getString("i"));
-        assertEquals("expected jsonen[id] == beanId", "beanId", jsonen.getString("id"));
-
-        /**
-         * Without the JSON-Java change, these keys would be stored internally as
-         * starting with the letter, 'ı' (dotless i), since the lowercasing of
-         * the getI and getId keys would be specific to the Turkish locale.
-         */
-        Locale.setDefault(new Locale("tr"));
-        JSONObject jsontr = new JSONObject(myLocaleBean);
-        assertEquals("expected size 2, found: " +jsontr.length(), 2, jsontr.length());
-        assertEquals("expected jsontr[i] == beanI", "beanI", jsontr.getString("i"));
-        assertEquals("expected jsontr[id] == beanId", "beanId", jsontr.getString("id"));
+        // save and restore the current default locale, to avoid any side effects on other executions in the same JVM
+        Locale defaultLocale = Locale.getDefault();
+        try {
+            /**
+             * This is just the control case which happens when the locale.ROOT
+             * lowercasing behavior is the same as the current locale.
+             */
+            Locale.setDefault(new Locale("en"));
+            JSONObject jsonen = new JSONObject(myLocaleBean);
+            assertEquals("expected size 2, found: " +jsonen.length(), 2, jsonen.length());
+            assertEquals("expected jsonen[i] == beanI", "beanI", jsonen.getString("i"));
+            assertEquals("expected jsonen[id] == beanId", "beanId", jsonen.getString("id"));
+
+            /**
+             * Without the JSON-Java change, these keys would be stored internally as
+             * starting with the letter, 'ı' (dotless i), since the lowercasing of
+             * the getI and getId keys would be specific to the Turkish locale.
+             */
+            Locale.setDefault(new Locale("tr"));
+            JSONObject jsontr = new JSONObject(myLocaleBean);
+            assertEquals("expected size 2, found: " +jsontr.length(), 2, jsontr.length());
+            assertEquals("expected jsontr[i] == beanI", "beanI", jsontr.getString("i"));
+            assertEquals("expected jsontr[id] == beanId", "beanId", jsontr.getString("id"));
+        } finally {
+            Locale.setDefault(defaultLocale);
+        }
     }
 }
diff --git a/src/test/java/org/json/junit/JSONObjectRecordTest.java b/src/test/java/org/json/junit/JSONObjectRecordTest.java
new file mode 100644
index 000000000..f1a673d28
--- /dev/null
+++ b/src/test/java/org/json/junit/JSONObjectRecordTest.java
@@ -0,0 +1,179 @@
+package org.json.junit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.StringReader;
+
+import org.json.JSONObject;
+import org.json.junit.data.GenericBeanInt;
+import org.json.junit.data.MyEnum;
+import org.json.junit.data.MyNumber;
+import org.json.junit.data.PersonRecord;
+import org.junit.Ignore;
+import org.junit.Test;
+
+/**
+ * Tests for JSONObject support of Java record types.
+ * 
+ * NOTE: These tests are currently ignored because PersonRecord is not an actual Java record.
+ * The implementation now correctly detects actual Java records using reflection (Class.isRecord()).
+ * These tests will need to be enabled and run with Java 17+ where PersonRecord can be converted
+ * to an actual record type.
+ * 
+ * This ensures backward compatibility - regular classes with lowercase method names will not
+ * be treated as records unless they are actual Java record types.
+ */
+public class JSONObjectRecordTest {
+
+    /**
+     * Tests that JSONObject can be created from a record-style class.
+     * Record-style classes use accessor methods like name() instead of getName().
+     * 
+     * NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
+     */
+    @Test
+    @Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
+    public void jsonObjectByRecord() {
+        PersonRecord person = new PersonRecord("John Doe", 30, true);
+        JSONObject jsonObject = new JSONObject(person);
+        
+        assertEquals("Expected 3 keys in the JSONObject", 3, jsonObject.length());
+        assertEquals("John Doe", jsonObject.get("name"));
+        assertEquals(30, jsonObject.get("age"));
+        assertEquals(true, jsonObject.get("active"));
+    }
+    
+    /**
+     * Test that Object methods (toString, hashCode, equals, etc.) are not included
+     * 
+     * NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
+     */
+    @Test
+    @Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
+    public void recordStyleClassShouldNotIncludeObjectMethods() {
+        PersonRecord person = new PersonRecord("Jane Doe", 25, false);
+        JSONObject jsonObject = new JSONObject(person);
+        
+        // Should NOT include Object methods
+        assertFalse("Should not include toString", jsonObject.has("toString"));
+        assertFalse("Should not include hashCode", jsonObject.has("hashCode"));
+        assertFalse("Should not include equals", jsonObject.has("equals"));
+        assertFalse("Should not include clone", jsonObject.has("clone"));
+        assertFalse("Should not include wait", jsonObject.has("wait"));
+        assertFalse("Should not include notify", jsonObject.has("notify"));
+        assertFalse("Should not include notifyAll", jsonObject.has("notifyAll"));
+        
+        // Should only have the 3 record fields
+        assertEquals("Should only have 3 fields", 3, jsonObject.length());
+    }
+    
+    /**
+     * Test that enum methods are not included when processing an enum
+     */
+    @Test
+    public void enumsShouldNotIncludeEnumMethods() {
+        MyEnum myEnum = MyEnum.VAL1;
+        JSONObject jsonObject = new JSONObject(myEnum);
+        
+        // Should NOT include enum-specific methods like name(), ordinal(), values(), valueOf()
+        assertFalse("Should not include name method", jsonObject.has("name"));
+        assertFalse("Should not include ordinal method", jsonObject.has("ordinal"));
+        assertFalse("Should not include declaringClass", jsonObject.has("declaringClass"));
+        
+        // Enums should still work with traditional getters if they have any
+        // But should not pick up the built-in enum methods
+    }
+    
+    /**
+     * Test that Number subclass methods are not included
+     */
+    @Test
+    public void numberSubclassesShouldNotIncludeNumberMethods() {
+        MyNumber myNumber = new MyNumber();
+        JSONObject jsonObject = new JSONObject(myNumber);
+        
+        // Should NOT include Number methods like intValue(), longValue(), etc.
+        assertFalse("Should not include intValue", jsonObject.has("intValue"));
+        assertFalse("Should not include longValue", jsonObject.has("longValue"));
+        assertFalse("Should not include doubleValue", jsonObject.has("doubleValue"));
+        assertFalse("Should not include floatValue", jsonObject.has("floatValue"));
+        
+        // Should include the actual getter
+        assertTrue("Should include number", jsonObject.has("number"));
+        assertEquals("Should have 1 field", 1, jsonObject.length());
+    }
+    
+    /**
+     * Test that generic bean with get() and is() methods works correctly
+     */
+    @Test
+    public void genericBeanWithGetAndIsMethodsShouldNotBeIncluded() {
+        GenericBeanInt bean = new GenericBeanInt(42);
+        JSONObject jsonObject = new JSONObject(bean);
+        
+        // Should NOT include standalone get() or is() methods
+        assertFalse("Should not include standalone 'get' method", jsonObject.has("get"));
+        assertFalse("Should not include standalone 'is' method", jsonObject.has("is"));
+        
+        // Should include the actual getters
+        assertTrue("Should include genericValue field", jsonObject.has("genericValue"));
+        assertTrue("Should include a field", jsonObject.has("a"));
+    }
+    
+    /**
+     * Test that java.* classes don't have their methods picked up
+     */
+    @Test
+    public void javaLibraryClassesShouldNotIncludeTheirMethods() {
+        StringReader reader = new StringReader("test");
+        JSONObject jsonObject = new JSONObject(reader);
+        
+        // Should NOT include java.io.Reader methods like read(), reset(), etc.
+        assertFalse("Should not include read method", jsonObject.has("read"));
+        assertFalse("Should not include reset method", jsonObject.has("reset"));
+        assertFalse("Should not include ready method", jsonObject.has("ready"));
+        assertFalse("Should not include skip method", jsonObject.has("skip"));
+        
+        // Reader should produce empty JSONObject (no valid properties)
+        assertEquals("Reader should produce empty JSON", 0, jsonObject.length());
+    }
+    
+    /**
+     * Test mixed case - object with both traditional getters and record-style accessors
+     * 
+     * NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
+     */
+    @Test
+    @Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
+    public void mixedGettersAndRecordStyleAccessors() {
+        // PersonRecord has record-style accessors: name(), age(), active()
+        // These should all be included
+        PersonRecord person = new PersonRecord("Mixed Test", 40, true);
+        JSONObject jsonObject = new JSONObject(person);
+        
+        assertEquals("Should have all 3 record-style fields", 3, jsonObject.length());
+        assertTrue("Should include name", jsonObject.has("name"));
+        assertTrue("Should include age", jsonObject.has("age"));
+        assertTrue("Should include active", jsonObject.has("active"));
+    }
+    
+    /**
+     * Test that methods starting with uppercase are not included (not valid record accessors)
+     * 
+     * NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
+     */
+    @Test
+    @Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
+    public void methodsStartingWithUppercaseShouldNotBeIncluded() {
+        PersonRecord person = new PersonRecord("Test", 50, false);
+        JSONObject jsonObject = new JSONObject(person);
+        
+        // Record-style accessors must start with lowercase
+        // Methods like Name(), Age() (uppercase) should not be picked up
+        // Our PersonRecord only has lowercase accessors, which is correct
+        
+        assertEquals("Should only have lowercase accessors", 3, jsonObject.length());
+    }
+}
diff --git a/src/test/java/org/json/junit/JSONObjectTest.java b/src/test/java/org/json/junit/JSONObjectTest.java
index 7ca6093b7..5c1d1a2eb 100644
--- a/src/test/java/org/json/junit/JSONObjectTest.java
+++ b/src/test/java/org/json/junit/JSONObjectTest.java
@@ -3117,12 +3117,13 @@ public void testJSONWriterException() {
         
         // test a more complex object
         writer = new StringWriter();
-        try {
-            new JSONObject()
+
+        JSONObject object = new JSONObject()
                 .put("somethingElse", "a value")
                 .put("someKey", new JSONArray()
-                        .put(new JSONObject().put("key1", new BrokenToString())))
-                .write(writer).toString();
+                        .put(new JSONObject().put("key1", new BrokenToString())));
+        try {
+            object.write(writer).toString();
             fail("Expected an exception, got a String value");
         } catch (JSONException e) {
             assertEquals("Unable to write JSONObject value for key: someKey", e.getMessage());
@@ -3133,17 +3134,18 @@ public void testJSONWriterException() {
                 writer.close();
             } catch (Exception e) {}
         }
-       
+
         // test a more slightly complex object
         writer = new StringWriter();
-        try {
-            new JSONObject()
+
+        object = new JSONObject()
                 .put("somethingElse", "a value")
                 .put("someKey", new JSONArray()
                         .put(new JSONObject().put("key1", new BrokenToString()))
                         .put(12345)
-                 )
-                .write(writer).toString();
+                 );
+        try {
+            object.write(writer).toString();
             fail("Expected an exception, got a String value");
         } catch (JSONException e) {
             assertEquals("Unable to write JSONObject value for key: someKey", e.getMessage());
diff --git a/src/test/java/org/json/junit/XMLConfigurationTest.java b/src/test/java/org/json/junit/XMLConfigurationTest.java
index ca1980c8a..e8ff3b60c 100755
--- a/src/test/java/org/json/junit/XMLConfigurationTest.java
+++ b/src/test/java/org/json/junit/XMLConfigurationTest.java
@@ -1092,7 +1092,7 @@ public void testEmptyForceList() {
                 "";
 
         String expectedStr = 
-                "{\"addresses\":[]}";
+                "{\"addresses\":[\"\"]}";
         
         Set forceList = new HashSet();
         forceList.add("addresses");
@@ -1130,7 +1130,7 @@ public void testEmptyTagForceList() {
                 "";
 
         String expectedStr = 
-                "{\"addresses\":[]}";
+                "{\"addresses\":[\"\"]}";
         
         Set forceList = new HashSet();
         forceList.add("addresses");
@@ -1144,6 +1144,157 @@ public void testEmptyTagForceList() {
         Util.compareActualVsExpectedJsonObjects(jsonObject, expetedJsonObject);
     }
 
+    @Test
+    public void testForceListWithLastElementAsEmptyTag(){
+        final String originalXml = "1";
+        final String expectedJsonString = "{\"root\":{\"id\":[1,\"\"]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        final JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withKeepStrings(false)
+                        .withcDataTagName("content")
+                        .withForceList(forceListCandidates)
+                        .withConvertNilAttributeToNull(true));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListWithFirstElementAsEmptyTag(){
+        final String originalXml = "1";
+        final String expectedJsonString = "{\"root\":{\"id\":[\"\",1]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        final JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withKeepStrings(false)
+                        .withcDataTagName("content")
+                        .withForceList(forceListCandidates)
+                        .withConvertNilAttributeToNull(true));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListWithMiddleElementAsEmptyTag(){
+        final String originalXml = "12";
+        final String expectedJsonString = "{\"root\":{\"id\":[1,\"\",2]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        final JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withKeepStrings(false)
+                        .withcDataTagName("content")
+                        .withForceList(forceListCandidates)
+                        .withConvertNilAttributeToNull(true));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListWithLastElementAsEmpty(){
+        final String originalXml = "1";
+        final String expectedJsonString = "{\"root\":{\"id\":[1,\"\"]}}";
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        final JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withKeepStrings(false)
+                        .withForceList(forceListCandidates)
+                        .withConvertNilAttributeToNull(true));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListWithFirstElementAsEmpty(){
+        final String originalXml = "1";
+        final String expectedJsonString = "{\"root\":{\"id\":[\"\",1]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        final JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withKeepStrings(false)
+                        .withForceList(forceListCandidates)
+                        .withConvertNilAttributeToNull(true));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListWithMiddleElementAsEmpty(){
+        final String originalXml = "12";
+        final String expectedJsonString = "{\"root\":{\"id\":[1,\"\",2]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        final JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withKeepStrings(false)
+                        .withForceList(forceListCandidates)
+                        .withConvertNilAttributeToNull(true));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListEmptyAndEmptyTagsMixed(){
+        final String originalXml = "12";
+        final String expectedJsonString = "{\"root\":{\"id\":[\"\",\"\",1,\"\",\"\",2]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        final JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withKeepStrings(false)
+                        .withForceList(forceListCandidates)
+                        .withConvertNilAttributeToNull(true));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListConsistencyWithDefault() {
+        final String originalXml = "01";
+        final String expectedJsonString = "{\"root\":{\"id\":[0,1,\"\",\"\"]}}";
+
+        // confirm expected result of default array-of-tags processing
+        JSONObject json = XML.toJSONObject(originalXml);
+        assertEquals(expectedJsonString, json.toString());
+
+        // confirm forceList array-of-tags processing is consistent with default processing
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withForceList(forceListCandidates));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListInitializesAnArrayWithAnEmptyElement(){
+        final String originalXml = "";
+        final String expectedJsonString = "{\"root\":{\"id\":[\"\"]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withForceList(forceListCandidates));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
+    @Test
+    public void testForceListInitializesAnArrayWithAnEmptyTag(){
+        final String originalXml = "";
+        final String expectedJsonString = "{\"root\":{\"id\":[\"\"]}}";
+
+        HashSet forceListCandidates = new HashSet<>();
+        forceListCandidates.add("id");
+        JSONObject json = XML.toJSONObject(originalXml,
+                new XMLParserConfiguration()
+                        .withForceList(forceListCandidates));
+        assertEquals(expectedJsonString, json.toString());
+    }
+
     @Test
     public void testMaxNestingDepthIsSet() {
         XMLParserConfiguration xmlParserConfiguration = XMLParserConfiguration.ORIGINAL;
diff --git a/src/test/java/org/json/junit/XMLTest.java b/src/test/java/org/json/junit/XMLTest.java
index 2fa5daeea..25b0a0e42 100644
--- a/src/test/java/org/json/junit/XMLTest.java
+++ b/src/test/java/org/json/junit/XMLTest.java
@@ -1426,6 +1426,81 @@ public void clarifyCurrentBehavior() {
         assertEquals(jsonObject3.getJSONObject("color").getString("value"), "008E97");
     }
 
+    /**
+     * Tests that empty numeric character reference &#; throws JSONException.
+     * Previously threw StringIndexOutOfBoundsException.
+     * Related to issue #1035
+     */
+    @Test(expected = JSONException.class)
+    public void testEmptyNumericEntityThrowsJSONException() {
+        String xmlStr = "&#;";
+        XML.toJSONObject(xmlStr);
+    }
+
+    /**
+     * Tests that malformed decimal entity &#txx; throws JSONException.
+     * Previously threw NumberFormatException.
+     * Related to issue #1036
+     */
+    @Test(expected = JSONException.class)
+    public void testInvalidDecimalEntityThrowsJSONException() {
+        String xmlStr = "&#txx;";
+        XML.toJSONObject(xmlStr);
+    }
+
+    /**
+     * Tests that empty hex entity &#x; throws JSONException.
+     * Validates proper input validation for hex entities.
+     */
+    @Test(expected = JSONException.class)
+    public void testEmptyHexEntityThrowsJSONException() {
+        String xmlStr = "&#x;";
+        XML.toJSONObject(xmlStr);
+    }
+
+    /**
+     * Tests that invalid hex entity &#xGGG; throws JSONException.
+     * Validates hex digit validation.
+     */
+    @Test(expected = JSONException.class)
+    public void testInvalidHexEntityThrowsJSONException() {
+        String xmlStr = "&#xGGG;";
+        XML.toJSONObject(xmlStr);
+    }
+
+    /**
+     * Tests that valid decimal numeric entity A works correctly.
+     * Should decode to character 'A'.
+     */
+    @Test
+    public void testValidDecimalEntity() {
+        String xmlStr = "A";
+        JSONObject jsonObject = XML.toJSONObject(xmlStr);
+        assertEquals("A", jsonObject.getString("a"));
+    }
+
+    /**
+     * Tests that valid hex numeric entity A works correctly.
+     * Should decode to character 'A'.
+     */
+    @Test
+    public void testValidHexEntity() {
+        String xmlStr = "A";
+        JSONObject jsonObject = XML.toJSONObject(xmlStr);
+        assertEquals("A", jsonObject.getString("a"));
+    }
+
+    /**
+     * Tests that valid uppercase hex entity A works correctly.
+     * Should decode to character 'A'.
+     */
+    @Test
+    public void testValidUppercaseHexEntity() {
+        String xmlStr = "A";
+        JSONObject jsonObject = XML.toJSONObject(xmlStr);
+        assertEquals("A", jsonObject.getString("a"));
+    }
+
 }
 
 
diff --git a/src/test/java/org/json/junit/data/PersonRecord.java b/src/test/java/org/json/junit/data/PersonRecord.java
new file mode 100644
index 000000000..891f1bb9e
--- /dev/null
+++ b/src/test/java/org/json/junit/data/PersonRecord.java
@@ -0,0 +1,31 @@
+package org.json.junit.data;
+
+/**
+ * A test class that mimics Java record accessor patterns.
+ * Records use accessor methods without get/is prefixes (e.g., name() instead of getName()).
+ * This class simulates that behavior to test JSONObject's handling of such methods.
+ */
+public class PersonRecord {
+    private final String name;
+    private final int age;
+    private final boolean active;
+
+    public PersonRecord(String name, int age, boolean active) {
+        this.name = name;
+        this.age = age;
+        this.active = active;
+    }
+
+    // Record-style accessors (no "get" or "is" prefix)
+    public String name() {
+        return name;
+    }
+
+    public int age() {
+        return age;
+    }
+
+    public boolean active() {
+        return active;
+    }
+}