Skip to content

Commit 8552710

Browse files
authored
Merge pull request #19205 from yoff/ruby/refine-uninitialised-local
ruby: refine `rb/uninitialized-local-variable`
2 parents 2dc88d8 + 7517272 commit 8552710

File tree

8 files changed

+222
-8
lines changed

8 files changed

+222
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: majorAnalysis
3+
---
4+
* The query `rb/uninitialized-local-variable` now only produces alerts when the variable is the receiver of a method call and should produce very few false positives. It also now comes with a help file.

ruby/ql/src/codeql-suites/ruby-code-quality.qls

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
- include:
33
id:
44
- rb/database-query-in-loop
5-
- rb/useless-assignment-to-local
5+
- rb/useless-assignment-to-local
6+
- rb/uninitialized-local-variable
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Method call on `nil`
2+
3+
## Description
4+
In Ruby, it is not necessary to explicitly initialize variables.
5+
If a local variable has not been explicitly initialized, it will have the value `nil`. If this happens unintended, though, the variable will not represent an object with the expected methods, and a method call on the variable will raise a `NoMethodError`.
6+
7+
## Recommendation
8+
9+
Ensure that the variable cannot be `nil` at the point hightligted by the alert.
10+
This can be achieved by using a safe navigation or adding a check for `nil`.
11+
12+
Note: You do not need to explicitly initialize the variable, if you can make the program deal with the possible `nil` value. In particular, initializing the variable to `nil` will have no effect, as this is already the value of the variable. If `nil` is the only possibly default value, you need to handle the `nil` value instead of initializing the variable.
13+
14+
## Examples
15+
16+
In the following code, the call to `create_file` may fail and then the call `f.close` will raise a `NoMethodError` since `f` will be `nil` at that point.
17+
18+
```ruby
19+
def dump(x)
20+
f = create_file
21+
f.puts(x)
22+
ensure
23+
f.close
24+
end
25+
```
26+
27+
We can fix this by using safe navigation:
28+
```ruby
29+
def dump(x)
30+
f = create_file
31+
f.puts(x)
32+
ensure
33+
f&.close
34+
end
35+
```
36+
37+
## References
38+
39+
- https://www.rubyguides.com/: [Nil](https://www.rubyguides.com/2018/01/ruby-nil/)
40+
- https://ruby-doc.org/: [NoMethodError](https://ruby-doc.org/core-2.6.5/NoMethodError.html)
41+

ruby/ql/src/queries/variables/UninitializedLocal.ql

+78-7
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,91 @@
55
* @kind problem
66
* @problem.severity error
77
* @id rb/uninitialized-local-variable
8-
* @tags reliability
8+
* @tags quality
9+
* reliability
910
* correctness
10-
* @precision low
11+
* @precision high
1112
*/
1213

1314
import codeql.ruby.AST
1415
import codeql.ruby.dataflow.SSA
16+
import codeql.ruby.controlflow.internal.Guards as Guards
17+
import codeql.ruby.controlflow.CfgNodes
18+
import codeql.ruby.ast.internal.Variable
1519

16-
class RelevantLocalVariableReadAccess extends LocalVariableReadAccess {
17-
RelevantLocalVariableReadAccess() {
18-
not exists(MethodCall c |
19-
c.getReceiver() = this and
20+
private predicate isInBooleanContext(AstNode n) {
21+
exists(ConditionalExpr i |
22+
n = i.getCondition()
23+
or
24+
isInBooleanContext(i) and
25+
n = i.getBranch(_)
26+
)
27+
or
28+
n = any(ConditionalLoop parent).getCondition()
29+
or
30+
n = any(InClause parent).getCondition()
31+
or
32+
n = any(LogicalAndExpr op).getAnOperand()
33+
or
34+
n = any(LogicalOrExpr op).getAnOperand()
35+
or
36+
n = any(NotExpr op).getOperand()
37+
or
38+
n = any(StmtSequence parent | isInBooleanContext(parent)).getLastStmt()
39+
or
40+
exists(CaseExpr c, WhenClause w |
41+
not exists(c.getValue()) and
42+
c.getABranch() = w
43+
|
44+
w.getPattern(_) = n
45+
or
46+
w = n
47+
)
48+
}
49+
50+
private predicate isGuarded(LocalVariableReadAccess read) {
51+
exists(AstCfgNode guard, boolean branch |
52+
Guards::guardControlsBlock(guard, read.getAControlFlowNode().getBasicBlock(), branch)
53+
|
54+
// guard is `var`
55+
guard.getAstNode() = read.getVariable().getAnAccess() and
56+
branch = true
57+
or
58+
// guard is `var.nil?`
59+
exists(MethodCall c | guard.getAstNode() = c |
60+
c.getReceiver() = read.getVariable().getAnAccess() and
2061
c.getMethodName() = "nil?"
21-
)
62+
) and
63+
branch = false
64+
)
65+
}
66+
67+
private predicate isNilChecked(LocalVariableReadAccess read) {
68+
exists(MethodCall c | c.getReceiver() = read |
69+
c.getMethodName() = "nil?"
70+
or
71+
c.isSafeNavigation()
72+
)
73+
}
74+
75+
/**
76+
* Holds if `name` is the name of a method defined on `nil`.
77+
* See https://ruby-doc.org/core-2.5.8/NilClass.html
78+
*/
79+
private predicate isNilMethodName(string name) {
80+
name in [
81+
"inspect", "instance_of?", "is_a?", "kind_of?", "method", "nil?", "rationalize", "to_a",
82+
"to_c", "to_f", "to_h", "to_i", "to_r", "to_s"
83+
]
84+
}
85+
86+
class RelevantLocalVariableReadAccess extends LocalVariableReadAccess instanceof TVariableAccessReal
87+
{
88+
RelevantLocalVariableReadAccess() {
89+
not isInBooleanContext(this) and
90+
not isNilChecked(this) and
91+
not isGuarded(this) and
92+
this = any(MethodCall m | not isNilMethodName(m.getMethodName())).getReceiver()
2293
}
2394
}
2495

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
def m
2+
puts "m"
3+
end
4+
5+
def foo
6+
m # calls m above
7+
if false
8+
m = "0"
9+
m # reads local variable m
10+
else
11+
end
12+
m.strip # reads uninitialized local variable m, `nil`, and crashes
13+
m2 # undefined local variable or method 'm2' for main (NameError)
14+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
| UninitializedLocal.rb:12:3:12:3 | m | Local variable $@ may be used before it is initialized. | UninitializedLocal.rb:8:7:8:7 | m | m |
2+
| UninitializedLocal.rb:34:5:34:5 | b | Local variable $@ may be used before it is initialized. | UninitializedLocal.rb:27:9:27:9 | b | b |
3+
| UninitializedLocal.rb:34:23:34:23 | b | Local variable $@ may be used before it is initialized. | UninitializedLocal.rb:27:9:27:9 | b | b |
4+
| UninitializedLocal.rb:76:5:76:5 | i | Local variable $@ may be used before it is initialized. | UninitializedLocal.rb:73:9:73:9 | i | i |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
query: queries/variables/UninitializedLocal.ql
2+
postprocess: utils/test/InlineExpectationsTestQuery.ql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
def m
2+
puts "m"
3+
end
4+
5+
def foo
6+
m # calls m above
7+
if false
8+
m = "0"
9+
m # reads local variable m
10+
else
11+
end
12+
m.strip #$ Alert
13+
m2 # undefined local variable or method 'm2' for main (NameError)
14+
end
15+
16+
def test_guards
17+
if (a = "3" && a) # OK - a is in a Boolean context
18+
a.strip
19+
end
20+
if (a = "3") && a # OK - a is assigned in the previous conjunct
21+
a.strip
22+
end
23+
if !(a = "3") or a # OK - a is assigned in the previous conjunct
24+
a.strip
25+
end
26+
if false
27+
b = "0"
28+
end
29+
b.nil?
30+
b || 0 # OK
31+
b&.strip # OK - safe navigation
32+
b.strip if b # OK
33+
b.close if b && !b.closed # OK
34+
b.blowup if b || !b.blownup #$ Alert
35+
36+
if false
37+
c = "0"
38+
end
39+
unless c
40+
return
41+
end
42+
c.strip # OK - given above unless
43+
44+
if false
45+
d = "0"
46+
end
47+
if (d.nil?)
48+
return
49+
end
50+
d.strip # OK - given above check
51+
52+
if false
53+
e = "0"
54+
end
55+
unless (!e.nil?)
56+
return
57+
end
58+
e.strip # OK - given above unless
59+
end
60+
61+
def test_loop
62+
begin
63+
if false
64+
a = 0
65+
else
66+
set_a
67+
end
68+
end until a # OK
69+
a.strip # OK - given previous until
70+
end
71+
72+
def test_for
73+
for i in ["foo", "bar"] # OK - since 0..10 cannot raise
74+
puts i.strip
75+
end
76+
i.strip #$ SPURIOUS: Alert
77+
end

0 commit comments

Comments
 (0)