Repository navigation
SONARJAVA-7243: Improve S8989 to ignore checked exceptions thrown before any write #6401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,11 +16,15 @@ | |
| */ | ||
| package org.sonar.java.checks.spring; | ||
|
|
||
| import java.util.ArrayDeque; | ||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.Deque; | ||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import java.util.function.Function; | ||
| import java.util.stream.Collectors; | ||
| import org.sonar.check.Rule; | ||
|
|
@@ -34,6 +38,8 @@ | |
| import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; | ||
| import org.sonar.plugins.java.api.JavaFileScannerContext; | ||
| import org.sonar.plugins.java.api.Version; | ||
| import org.sonar.plugins.java.api.cfg.ControlFlowGraph; | ||
| import org.sonar.plugins.java.api.semantic.Symbol; | ||
| import org.sonar.plugins.java.api.semantic.Type; | ||
| import org.sonar.plugins.java.api.tree.AnnotationTree; | ||
| import org.sonar.plugins.java.api.tree.Arguments; | ||
|
|
@@ -50,6 +56,7 @@ | |
| import org.sonar.plugins.java.api.tree.NewClassTree; | ||
| import org.sonar.plugins.java.api.tree.Modifier; | ||
| import org.sonar.plugins.java.api.tree.SyntaxToken; | ||
| import org.sonar.plugins.java.api.tree.ThrowStatementTree; | ||
| import org.sonar.plugins.java.api.tree.Tree; | ||
| import org.sonar.plugins.java.api.tree.TypeTree; | ||
|
|
||
|
|
@@ -113,6 +120,10 @@ public void visitNode(Tree tree) { | |
| return; | ||
| } | ||
|
|
||
| if (allCheckedExceptionsThrownBeforeWrites(method)) { | ||
| return; | ||
| } | ||
|
|
||
| var issueBuilder = QuickFixHelper.newIssue(context) | ||
| .forRule(this) | ||
| .onTree(method.simpleName()); | ||
|
|
@@ -296,6 +307,93 @@ private static boolean looksTransactional(MethodTree method) { | |
| return visitor.foundTransactionalCall; | ||
| } | ||
|
|
||
| /** | ||
| * Uses the control flow graph of the method to determine whether every point that may throw a checked exception | ||
| * is executed before any write operation. In that case, no write can have happened when a checked exception is | ||
| * thrown, so the rollback behavior is irrelevant. | ||
| * Returns false when the method has no body, no write operation or no point throwing a checked exception. | ||
| */ | ||
| private static boolean allCheckedExceptionsThrownBeforeWrites(MethodTree method) { | ||
| ControlFlowGraph cfg = method.cfg(); | ||
| if (cfg == null) { | ||
| return false; | ||
| } | ||
| boolean hasWrite = false; | ||
| boolean hasThrowPoint = false; | ||
| for (ControlFlowGraph.Block block : cfg.blocks()) { | ||
| List<Tree> items = blockItems(block); | ||
| for (int i = 0; i < items.size(); i++) { | ||
| hasThrowPoint |= isCheckedThrowPoint(items.get(i)); | ||
| if (isWriteOperation(items.get(i))) { | ||
| hasWrite = true; | ||
| if (items.subList(i, items.size()).stream().anyMatch(TransactionalMethodCheckedExceptionCheck::isCheckedThrowPoint) | ||
| || hasReachableThrowPoint(block)) { | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return hasWrite && hasThrowPoint; | ||
|
Comment on lines
+323
to
+336
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Edge Case: Writes not matching the name prefixes before a throw now cause false negativesThe issue is skipped as soon as every detected write comes after every checked throw point. Writes are detected only by method-name prefix ( Was this helpful? React with 👍 / 👎 |
||
| } | ||
|
|
||
| private static List<Tree> blockItems(ControlFlowGraph.Block block) { | ||
| Tree terminator = block.terminator(); | ||
| if (terminator == null) { | ||
| return block.elements(); | ||
| } | ||
| List<Tree> items = new ArrayList<>(block.elements()); | ||
| items.add(terminator); | ||
| return items; | ||
| } | ||
|
|
||
| private static boolean hasReachableThrowPoint(ControlFlowGraph.Block start) { | ||
| Set<ControlFlowGraph.Block> visited = new HashSet<>(); | ||
| Deque<ControlFlowGraph.Block> toVisit = new ArrayDeque<>(); | ||
| addSuccessors(start, toVisit); | ||
| while (!toVisit.isEmpty()) { | ||
| ControlFlowGraph.Block block = toVisit.pop(); | ||
| if (visited.add(block)) { | ||
| if (blockItems(block).stream().anyMatch(TransactionalMethodCheckedExceptionCheck::isCheckedThrowPoint)) { | ||
| return true; | ||
| } | ||
| addSuccessors(block, toVisit); | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| private static void addSuccessors(ControlFlowGraph.Block block, Deque<ControlFlowGraph.Block> toVisit) { | ||
| toVisit.addAll(block.successors()); | ||
| toVisit.addAll(block.exceptions()); | ||
| } | ||
|
|
||
| private static boolean isWriteOperation(Tree tree) { | ||
| if (!(tree instanceof MethodInvocationTree) && !(tree instanceof NewClassTree)) { | ||
| return false; | ||
| } | ||
| var visitor = new MethodInvocationVisitor(); | ||
| tree.accept(visitor); | ||
| return visitor.foundTransactionalCall; | ||
| } | ||
|
|
||
| private static boolean isCheckedThrowPoint(Tree tree) { | ||
| if (tree instanceof ThrowStatementTree throwStatement) { | ||
| Type type = throwStatement.expression().symbolType(); | ||
| return type.isUnknown() || isCheckedException(type); | ||
| } | ||
| if (tree instanceof MethodInvocationTree mit) { | ||
| return mayThrowCheckedException(mit.methodSymbol()); | ||
| } | ||
| if (tree instanceof NewClassTree newClass) { | ||
| return mayThrowCheckedException(newClass.methodSymbol()); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| private static boolean mayThrowCheckedException(Symbol.MethodSymbol symbol) { | ||
| return symbol.isUnknown() || symbol.thrownTypes().stream().anyMatch(type -> type.isUnknown() || isCheckedException(type)); | ||
| } | ||
|
|
||
| private static boolean hasTransactionalPrefix(String name) { | ||
| String lowerName = name.toLowerCase(Locale.ROOT); | ||
| return TRANSACTIONAL_PREFIXES.stream().anyMatch(prefix -> { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CFG does not model the implicit
close()calls of try-with-resources.CFG.javabuilds only the resource list and says so in a comment: "FIXME only path with no failure constructed for now, (not taking try with resources into consideration)". SohasReachableThrowPointnever sees theclose()that runs after the body. Example:try (BufferedReader r = Files.newBufferedReader(p)) { repository.save(o); }in a method declaringthrows IOException.newBufferedReaderis a checked throw point before the write, and nothing checked is found aftersave, so the issue is now skipped. Butr.close()can throwIOExceptionafter the save, and Spring will then commit the write instead of rolling it back. Fix: if a write sits inside a try-with-resources whose resource types have aclose()that declares checked exceptions, treat it as having a reachable throw point, or skip the optimisation when the method contains try-with-resources. Add a Noncompliant sample for this case.Was this helpful? React with 👍 / 👎