使用源位置¶
您可以使用 Java/Kotlin 代码中实体的位置来查找潜在的错误。位置允许您推断空白的存在与否,这在某些情况下可能表示问题。
注意
Kotlin 的 CodeQL 分析目前处于测试阶段。在测试阶段,对 Kotlin 代码的分析以及相关文档将不如其他语言全面。
关于源位置¶
Java 提供了一套丰富的运算符,具有复杂的优先级规则,这些规则有时会让开发人员感到困惑。例如,OpenJDK Java 编译器中的 ByteBufferCache 类(它是 com.sun.tools.javac.util.BaseFileManager 的成员类)包含以下用于分配缓冲区的代码
ByteBuffer.allocate(capacity + capacity>>1)
可以推测,作者的本意是分配一个缓冲区,其大小为变量 capacity 指示大小的 1.5 倍。但实际上,+ 运算符的绑定优先级高于 >> 运算符,因此表达式 capacity + capacity>>1 被解析为 (capacity + capacity)>>1,其结果等于 capacity(除非发生算术溢出)。
请注意,源代码布局相当清楚地表明了预期的含义:+ 周围的空白比 >> 周围的空白更多,这表明后者应该具有更高的绑定优先级。
我们将开发一个查询来查找这种可疑的嵌套,其中内部表达式的运算符周围的空白比外部表达式的运算符周围的空白更多。这种模式并不一定表示错误,但至少会使代码难以阅读并容易被误解。
空白在 CodeQL 数据库中没有直接表示,但我们可以从与程序元素和 AST 节点关联的位置信息中推断出它的存在。因此,在我们编写查询之前,我们需要了解 Java 标准库中的源位置管理。
位置 API¶
对于在 Java 源代码中具有表示形式的每个实体(特别是程序元素和 AST 节点),CodeQL 标准库提供了以下谓词来访问源位置信息
getLocation返回一个Location对象,描述实体的起始位置和结束位置。getFile返回一个File对象,表示包含该实体的文件。getTotalNumberOfLines返回实体的源代码所跨越的行数。getNumberOfCommentLines返回注释行的数量。getNumberOfLinesOfCode返回非注释行的数量。
例如,假设此 Java 类在编译单元 SayHello.java 中定义
package pkg;
class SayHello {
public static void main(String[] args) {
System.out.println(
// Display personalized message
"Hello, " + args[0];
);
}
}
对 main 主体中的表达式语句调用 getFile 会返回一个表示文件 SayHello.java 的 File 对象。该语句总共跨越四行 (getTotalNumberOfLines),其中一行是注释行 (getNumberOfCommentLines),而三行包含代码 (getNumberOfLinesOfCode)。
Location 类定义了成员谓词 getStartLine、getEndLine、getStartColumn 和 getEndColumn,分别用于检索实体的起始行号和结束行号,以及起始列号和结束列号。行号和列号都是从 1 开始计数(而不是 0),并且结束位置是包含的,也就是说,它是属于实体源代码的最后一个字符的位置。
在我们的示例中,表达式语句从第 5 行第 3 列开始(该行的前两个字符是制表符,每个制表符算作一个字符),并在第 8 行第 4 列结束。
File 类定义了以下成员谓词
getAbsolutePath返回文件的完全限定名。getRelativePath返回文件相对于源代码基目录的路径。getExtension返回文件的扩展名。getStem返回文件的基本名称,不包括其扩展名。
在我们的示例中,假设文件 A.java 位于目录 /home/testuser/code/pkg 中,其中 /home/testuser/code 是正在分析的程序的基目录。那么,A.java 的 File 对象将返回
getAbsolutePath为/home/testuser/code/pkg/A.java。getRelativePath为pkg/A.java。getExtension为java。getStem为A。
确定运算符周围的空白¶
让我们首先考虑如何编写一个谓词来计算给定二元表达式的运算符周围的空白总数。如果 rcol 是表达式右操作数的起始列,而 lcol 是其左操作数的结束列,那么 rcol - (lcol+1) 给出了两个操作数之间字符的总数(请注意,我们必须使用 lcol+1 而不是 lcol,因为结束位置是包含的)。
此数字包括运算符本身的长度,我们需要将其减去。为此,我们可以使用谓词 getOp,它返回运算符字符串,两侧各带有一个空格。总的来说,用于计算二元表达式 expr 的运算符周围的空白量的表达式为
rcol - (lcol+1) - (expr.getOp().length()-2)
但是,这显然只在整个表达式都位于单行上的情况下有效,我们可以使用上面介绍的谓词 getTotalNumberOfLines 进行检查。现在,我们可以定义一个用于计算运算符周围的空白量的谓词
int operatorWS(BinaryExpr expr) {
exists(int lcol, int rcol |
expr.getNumberOfLinesOfCode() = 1 and
lcol = expr.getLeftOperand().getLocation().getEndColumn() and
rcol = expr.getRightOperand().getLocation().getStartColumn() and
result = rcol - (lcol+1) - (expr.getOp().length()-2)
)
}
请注意,我们使用 exists 来引入临时变量 lcol 和 rcol。您可以在不使用它们的情况下编写谓词,只需将 lcol 和 rcol 内联到它们的用法中,但这会降低可读性。
查找可疑嵌套¶
以下是查询的第一个版本
import java
// Insert predicate defined above
from BinaryExpr outer, BinaryExpr inner,
int wsouter, int wsinner
where inner = outer.getAChildExpr() and
wsinner = operatorWS(inner) and wsouter = operatorWS(outer) and
wsinner > wsouter
select outer, "Whitespace around nested operators contradicts precedence."
此查询可能会在大多数代码库中找到结果。
where 子句的第一个合取式将 inner 限制为 outer 的操作数,第二个合取式绑定 wsinner 和 wsouter,而最后一个合取式选择可疑的案例。
首先,我们可能很想在第一个合取式中编写 inner = outer.getAnOperand()。但是,这样做并不完全正确:getAnOperand 会从其结果中剥离任何周围的括号,这在很多情况下非常有用,但在这里并不是我们想要的:如果内部表达式周围有括号,那么程序员可能知道他们在做什么,查询不应该标记此表达式。
改进查询¶
如果运行此初始查询,我们可能会注意到一些由非对称空白引起的误报。例如,以下表达式被标记为可疑,尽管在实践中它不太可能造成混淆
i< start + 100
请注意,我们的谓词 operatorWS 计算运算符周围的总空白量,在本例中,< 为 1,+ 为 2。理想情况下,我们希望排除运算符前后空白量不相等的情况。目前,CodeQL 数据库没有记录足够的信息来确定这一点,但作为近似值,我们可以要求空白字符的总数为偶数
import java
// Insert predicate definition from above
from BinaryExpr outer, BinaryExpr inner,
int wsouter, int wsinner
where inner = outer.getAChildExpr() and
wsinner = operatorWS(inner) and wsouter = operatorWS(outer) and
wsinner % 2 = 0 and wsouter % 2 = 0 and
wsinner > wsouter
select outer, "Whitespace around nested operators contradicts precedence."
查询的更改将对任何结果进行细化。
另一个导致误报的来源是关联运算符:在形如 x + y+z 的表达式中,第一个加号在语法上嵌套在第二个加号内部,因为 Java 中的 + 运算符是左结合的;因此该表达式被标记为可疑。但是,由于 + 本身是结合的,所以运算符的嵌套方式无关紧要,因此这是一个误报。为了排除这些情况,让我们定义一个新的类来识别具有关联运算符的二元表达式。
class AssociativeOperator extends BinaryExpr {
AssociativeOperator() {
this instanceof AddExpr or
this instanceof MulExpr or
this instanceof BitwiseExpr or
this instanceof AndLogicalExpr or
this instanceof OrLogicalExpr
}
}
现在我们可以扩展我们的查询,以丢弃外部表达式和内部表达式都具有相同关联运算符的结果。
import java
// Insert predicate and class definitions from above
from BinaryExpr inner, BinaryExpr outer, int wsouter, int wsinner
where inner = outer.getAChildExpr() and
not (inner.getOp() = outer.getOp() and outer instanceof AssociativeOperator) and
wsinner = operatorWS(inner) and wsouter = operatorWS(outer) and
wsinner % 2 = 0 and wsouter % 2 = 0 and
wsinner > wsouter
select outer, "Whitespace around nested operators contradicts precedence."
请注意,我们再次使用了 getOp,这次是为了确定两个二元表达式是否具有相同的运算符。运行我们改进后的查询现在找到了概述中描述的 Java/Kotlin 标准库错误。它还标记了 Hadoop HBase 中的以下可疑代码。
KEY_SLAVE = tmp[ i+1 % 2 ];
空格表明程序员原本打算将 i 在零和一之间切换,但实际上该表达式被解析为 i + (1%2),这与 i + 1 相同,因此 i 只是被递增。