From e7a2c47be79802e1b8ac1db18f7a438eafbc75cd Mon Sep 17 00:00:00 2001 From: nathsou Date: Tue, 15 Sep 2026 14:19:33 +0200 Subject: [PATCH 1/3] Implement S9401 --- ...entityHashMapBoxedKeyCheckGuavaSample.java | 28 ++++++++ .../IdentityHashMapBoxedKeyCheckSample.java | 35 +++++++++ .../checks/IdentityHashMapBoxedKeyCheck.java | 72 +++++++++++++++++++ .../IdentityHashMapBoxedKeyCheckTest.java | 68 ++++++++++++++++++ .../org/sonar/l10n/java/rules/java/S9401.html | 29 ++++++++ .../org/sonar/l10n/java/rules/java/S9401.json | 23 ++++++ .../main/resources/profiles/Sonar_way/S9401 | 0 7 files changed, 255 insertions(+) create mode 100644 java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java create mode 100644 java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java create mode 100644 java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java create mode 100644 java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.json create mode 100644 sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9401 diff --git a/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java new file mode 100644 index 00000000000..a0defaa7f04 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java @@ -0,0 +1,28 @@ +package checks; + +import com.google.common.collect.Maps; +import java.util.Map; + +class IdentityHashMapBoxedKeyCheckGuavaSample { + + void boxedKeys() { + Map booleans = Maps.newIdentityHashMap(); // Noncompliant + Map bytes = Maps.newIdentityHashMap(); // Noncompliant + Map characters = Maps.newIdentityHashMap(); // Noncompliant + Map shorts = Maps.newIdentityHashMap(); // Noncompliant + Map integers = Maps.newIdentityHashMap(); // Noncompliant + Map longs = Maps.newIdentityHashMap(); // Noncompliant + Map floats = Maps.newIdentityHashMap(); // Noncompliant + Map doubles = Maps.newIdentityHashMap(); // Noncompliant + } + + void compliant() { + Map boxedValue = Maps.newIdentityHashMap(); + Map strings = Maps.newIdentityHashMap(); + Map objects = Maps.newIdentityHashMap(); + } + + void genericKey() { + Map generic = Maps.newIdentityHashMap(); + } +} diff --git a/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java new file mode 100644 index 00000000000..9bd39076967 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java @@ -0,0 +1,35 @@ +package checks; + +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; + +class IdentityHashMapBoxedKeyCheckSample { + + void boxedKeys(Map source) { + Map booleans = new IdentityHashMap<>(); // Noncompliant + Map bytes = new IdentityHashMap<>(); // Noncompliant + Map characters = new IdentityHashMap<>(); // Noncompliant + Map shorts = new IdentityHashMap<>(); // Noncompliant + Map integers = new IdentityHashMap<>(); // Noncompliant + Map longs = new IdentityHashMap<>(); // Noncompliant + Map floats = new IdentityHashMap<>(); // Noncompliant + Map doubles = new IdentityHashMap<>(); // Noncompliant + Map explicit = new IdentityHashMap(); // Noncompliant + Map copied = new IdentityHashMap<>(source); // Noncompliant + var inferred = new IdentityHashMap(); // Noncompliant {{Use a non-boxed key type because IdentityHashMap compares keys by reference.}} +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + } + + void compliant() { + Map boxedValue = new IdentityHashMap<>(); + Map strings = new IdentityHashMap<>(); + Map objects = new IdentityHashMap<>(); + Map hashMap = new HashMap<>(); + IdentityHashMap raw = new IdentityHashMap(); + } + + void genericKey() { + Map generic = new IdentityHashMap<>(); + } +} diff --git a/java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java b/java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java new file mode 100644 index 00000000000..86b42ef46ef --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java @@ -0,0 +1,72 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import java.util.List; +import java.util.Set; +import org.sonar.check.Rule; +import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; +import org.sonar.plugins.java.api.semantic.MethodMatchers; +import org.sonar.plugins.java.api.semantic.Type; +import org.sonar.plugins.java.api.tree.ExpressionTree; +import org.sonar.plugins.java.api.tree.MethodInvocationTree; +import org.sonar.plugins.java.api.tree.Tree; + +@Rule(key = "S9401") +public class IdentityHashMapBoxedKeyCheck extends IssuableSubscriptionVisitor { + + private static final String IDENTITY_HASH_MAP = "java.util.IdentityHashMap"; + private static final String MESSAGE = "Use a non-boxed key type because IdentityHashMap compares keys by reference."; + private static final Set BOXED_TYPES = Set.of( + "java.lang.Boolean", + "java.lang.Byte", + "java.lang.Character", + "java.lang.Short", + "java.lang.Integer", + "java.lang.Long", + "java.lang.Float", + "java.lang.Double"); + private static final MethodMatchers GUAVA_IDENTITY_HASH_MAP_FACTORY = MethodMatchers.create() + .ofTypes("com.google.common.collect.Maps") + .names("newIdentityHashMap") + .addWithoutParametersMatcher() + .build(); + + @Override + public List nodesToVisit() { + return List.of(Tree.Kind.NEW_CLASS, Tree.Kind.METHOD_INVOCATION); + } + + @Override + public void visitNode(Tree tree) { + ExpressionTree expression = (ExpressionTree) tree; + if (isIdentityHashMapCreation(tree, expression.symbolType()) && hasBoxedKey(expression.symbolType())) { + reportIssue(tree, MESSAGE); + } + } + + private static boolean isIdentityHashMapCreation(Tree tree, Type resultType) { + return tree.is(Tree.Kind.NEW_CLASS) + ? resultType.is(IDENTITY_HASH_MAP) + : GUAVA_IDENTITY_HASH_MAP_FACTORY.matches((MethodInvocationTree) tree); + } + + private static boolean hasBoxedKey(Type resultType) { + List typeArguments = resultType.typeArguments(); + return typeArguments.size() == 2 && BOXED_TYPES.contains(typeArguments.get(0).fullyQualifiedName()); + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java new file mode 100644 index 00000000000..cb9b57b97af --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java @@ -0,0 +1,68 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import com.google.common.collect.Maps; +import java.io.File; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; +import org.sonar.java.test.classpath.TestClasspathUtils; + +import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath; + +class IdentityHashMapBoxedKeyCheckTest { + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/IdentityHashMapBoxedKeyCheckSample.java")) + .withCheck(new IdentityHashMapBoxedKeyCheck()) + .verifyIssues(); + } + + @Test + void test_without_semantic() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/IdentityHashMapBoxedKeyCheckSample.java")) + .withCheck(new IdentityHashMapBoxedKeyCheck()) + .withoutSemantic() + .verifyIssues(); + } + + @Test + void test_guava() throws URISyntaxException { + List classPath = new ArrayList<>(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + classPath.add(new File(Maps.class.getProtectionDomain().getCodeSource().getLocation().toURI())); + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/IdentityHashMapBoxedKeyCheckGuavaSample.java")) + .withCheck(new IdentityHashMapBoxedKeyCheck()) + .withClassPath(classPath) + .verifyIssues(); + } + + @Test + void test_guava_without_semantic() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/IdentityHashMapBoxedKeyCheckGuavaSample.java")) + .withCheck(new IdentityHashMapBoxedKeyCheck()) + .withoutSemantic() + .verifyNoIssues(); + } +} diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html new file mode 100644 index 00000000000..f53d4885aaf --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html @@ -0,0 +1,29 @@ +

Why is this an issue?

+

IdentityHashMap compares keys by reference instead of calling equals(). Boxed primitive types represent values as +objects, but boxing the same primitive value more than once does not always return the same object. For example, some Integer values are +cached while others may be represented by distinct instances.

+

Consequently, an IdentityHashMap with boxed primitive keys can appear to work for some values and fail for others. A lookup can return +null even when the map contains a key with the same primitive value.

+

Use a map implementation that compares keys by value when the key represents a primitive value. If reference equality is required, use key objects +whose identity is deliberate and stable.

+
+Map<Integer, UserSession> sessions = new IdentityHashMap<>(); // Noncompliant
+
+sessions.put(500, session);
+UserSession retrieved = sessions.get(500); // May return null
+
+
+Map<Integer, UserSession> sessions = new HashMap<>();
+
+sessions.put(500, session);
+UserSession retrieved = sessions.get(500);
+
+

Resources

+

Documentation

+ diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.json new file mode 100644 index 00000000000..cc5dd5b4ff0 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.json @@ -0,0 +1,23 @@ +{ + "title": "IdentityHashMap keys should not be boxed primitive types", + "type": "BUG", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5min" + }, + "tags": [ + "suspicious" + ], + "defaultSeverity": "Major", + "ruleSpecification": "RSPEC-9401", + "sqKey": "S9401", + "scope": "All", + "quickfix": "infeasible", + "code": { + "impacts": { + "RELIABILITY": "MEDIUM" + }, + "attribute": "LOGICAL" + } +} diff --git a/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9401 b/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9401 new file mode 100644 index 00000000000..e69de29bb2d From 83322d77678810854b246f8ddd8a3d3532dcbe06 Mon Sep 17 00:00:00 2001 From: nathsou Date: Tue, 15 Sep 2026 14:36:07 +0200 Subject: [PATCH 2/3] Address review feedback --- .../checks/IdentityHashMapBoxedKeyCheckSample.java | 2 +- .../java/checks/IdentityHashMapBoxedKeyCheck.java | 2 +- .../java/checks/IdentityHashMapBoxedKeyCheckTest.java | 11 +---------- .../org/sonar/l10n/java/rules/java/S9401.html | 1 + 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java index 9bd39076967..3d70858f1e1 100644 --- a/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckSample.java @@ -17,7 +17,7 @@ void boxedKeys(Map source) { Map doubles = new IdentityHashMap<>(); // Noncompliant Map explicit = new IdentityHashMap(); // Noncompliant Map copied = new IdentityHashMap<>(source); // Noncompliant - var inferred = new IdentityHashMap(); // Noncompliant {{Use a non-boxed key type because IdentityHashMap compares keys by reference.}} + var inferred = new IdentityHashMap(); // Noncompliant {{Use a map that compares keys by value because IdentityHashMap compares keys by reference.}} // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ } diff --git a/java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java b/java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java index 86b42ef46ef..260b6788c24 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheck.java @@ -30,7 +30,7 @@ public class IdentityHashMapBoxedKeyCheck extends IssuableSubscriptionVisitor { private static final String IDENTITY_HASH_MAP = "java.util.IdentityHashMap"; - private static final String MESSAGE = "Use a non-boxed key type because IdentityHashMap compares keys by reference."; + private static final String MESSAGE = "Use a map that compares keys by value because IdentityHashMap compares keys by reference."; private static final Set BOXED_TYPES = Set.of( "java.lang.Boolean", "java.lang.Byte", diff --git a/java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java index cb9b57b97af..11127811070 100644 --- a/java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java +++ b/java-checks/src/test/java/org/sonar/java/checks/IdentityHashMapBoxedKeyCheckTest.java @@ -16,14 +16,8 @@ */ package org.sonar.java.checks; -import com.google.common.collect.Maps; -import java.io.File; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; import org.junit.jupiter.api.Test; import org.sonar.java.checks.verifier.CheckVerifier; -import org.sonar.java.test.classpath.TestClasspathUtils; import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath; @@ -47,13 +41,10 @@ void test_without_semantic() { } @Test - void test_guava() throws URISyntaxException { - List classPath = new ArrayList<>(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - classPath.add(new File(Maps.class.getProtectionDomain().getCodeSource().getLocation().toURI())); + void test_guava() { CheckVerifier.newVerifier() .onFile(mainCodeSourcesPath("checks/IdentityHashMapBoxedKeyCheckGuavaSample.java")) .withCheck(new IdentityHashMapBoxedKeyCheck()) - .withClassPath(classPath) .verifyIssues(); } diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html index f53d4885aaf..da5c7604afc 100644 --- a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html @@ -1,3 +1,4 @@ +

This rule raises an issue when an IdentityHashMap is created with a boxed primitive type as its key type.

Why is this an issue?

IdentityHashMap compares keys by reference instead of calling equals(). Boxed primitive types represent values as objects, but boxing the same primitive value more than once does not always return the same object. For example, some Integer values are From 3de3f097d0be300fe883c0460169e188d3b52223 Mon Sep 17 00:00:00 2001 From: nathsou Date: Tue, 15 Sep 2026 16:32:21 +0200 Subject: [PATCH 3/3] Address documentation review feedback --- .../java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java | 3 ++- .../main/resources/org/sonar/l10n/java/rules/java/S9401.html | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java index a0defaa7f04..81c1bd54542 100644 --- a/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/IdentityHashMapBoxedKeyCheckGuavaSample.java @@ -6,7 +6,8 @@ class IdentityHashMapBoxedKeyCheckGuavaSample { void boxedKeys() { - Map booleans = Maps.newIdentityHashMap(); // Noncompliant + Map booleans = Maps.newIdentityHashMap(); // Noncompliant {{Use a map that compares keys by value because IdentityHashMap compares keys by reference.}} +// ^^^^^^^^^^^^^^^^^^^^^^^^^ Map bytes = Maps.newIdentityHashMap(); // Noncompliant Map characters = Maps.newIdentityHashMap(); // Noncompliant Map shorts = Maps.newIdentityHashMap(); // Noncompliant diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html index da5c7604afc..53383f5df18 100644 --- a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9401.html @@ -5,14 +5,18 @@

Why is this an issue?

cached while others may be represented by distinct instances.

Consequently, an IdentityHashMap with boxed primitive keys can appear to work for some values and fail for others. A lookup can return null even when the map contains a key with the same primitive value.

+

How to fix it

Use a map implementation that compares keys by value when the key represents a primitive value. If reference equality is required, use key objects whose identity is deliberate and stable.

+

Code examples

+

Noncompliant code example

 Map<Integer, UserSession> sessions = new IdentityHashMap<>(); // Noncompliant
 
 sessions.put(500, session);
 UserSession retrieved = sessions.get(500); // May return null
 
+

Compliant solution

 Map<Integer, UserSession> sessions = new HashMap<>();