|
13 | 13 | */ |
14 | 14 |
|
15 | 15 | import python |
16 | | -import Expressions.CallArgs |
| 16 | +import semmle.python.dataflow.new.DataFlow |
| 17 | +import semmle.python.dataflow.new.internal.DataFlowDispatch |
| 18 | +import codeql.util.Option |
17 | 19 |
|
18 | | -from FunctionValue base, PythonFunctionValue derived |
19 | | -where |
20 | | - not exists(base.getACall()) and |
21 | | - not exists(FunctionValue a_derived | |
22 | | - a_derived.overrides(base) and |
23 | | - exists(a_derived.getACall()) |
| 20 | +/** Holds if `base` is overridden by `sub` */ |
| 21 | +predicate overrides(Function base, Function sub) { |
| 22 | + base.getName() = sub.getName() and |
| 23 | + base.getScope() = getADirectSuperclass+(sub.getScope()) |
| 24 | +} |
| 25 | + |
| 26 | +/** Constructs a string to pluralize `str` depending on `num`. */ |
| 27 | +bindingset[num, str] |
| 28 | +string plural(int num, string str) { |
| 29 | + num = 1 and result = "1 " + str |
| 30 | + or |
| 31 | + num != 1 and result = num.toString() + " " + str + "s" |
| 32 | +} |
| 33 | + |
| 34 | +/** Describes the minimum number of arguments `func` can accept, using "at least" if it may accept more. */ |
| 35 | +string describeMin(Function func) { |
| 36 | + exists(string descr | descr = plural(func.getMinPositionalArguments(), "positional argument") | |
| 37 | + if func.getMinPositionalArguments() = func.getMaxPositionalArguments() |
| 38 | + then result = descr |
| 39 | + else result = "at least " + descr |
| 40 | + ) |
| 41 | +} |
| 42 | + |
| 43 | +/** Described the maximum number of arguments `func` can accept, using "at most" if it may accept fewer, and "arbitrarily many" if it has a vararg. */ |
| 44 | +string describeMax(Function func) { |
| 45 | + if func.hasVarArg() |
| 46 | + then result = "arbitrarily many positional arguments" |
| 47 | + else |
| 48 | + exists(string descr | descr = plural(func.getMaxPositionalArguments(), "positional argument") | |
| 49 | + if func.getMinPositionalArguments() = func.getMaxPositionalArguments() |
| 50 | + then result = descr |
| 51 | + else result = "at most " + descr |
| 52 | + ) |
| 53 | +} |
| 54 | + |
| 55 | +/** Describes the minimum number of arguments `func` can accept, without repeating "positional arguments". */ |
| 56 | +string describeMinShort(Function func) { |
| 57 | + exists(string descr | descr = func.getMinPositionalArguments().toString() | |
| 58 | + if func.getMinPositionalArguments() = func.getMaxPositionalArguments() |
| 59 | + then result = descr |
| 60 | + else result = "at least " + descr |
| 61 | + ) |
| 62 | +} |
| 63 | + |
| 64 | +/** Describes the maximum number of arguments `func` can accept, without repeating "positional arguments". */ |
| 65 | +string describeMaxShort(Function func) { |
| 66 | + if func.hasVarArg() |
| 67 | + then result = "arbitrarily many" |
| 68 | + else |
| 69 | + exists(string descr | descr = func.getMaxPositionalArguments().toString() | |
| 70 | + if func.getMinPositionalArguments() = func.getMaxPositionalArguments() |
| 71 | + then result = descr |
| 72 | + else result = "at most " + descr |
| 73 | + ) |
| 74 | +} |
| 75 | + |
| 76 | +/** Describe an upper bound on the number of arguments `func` may accept, without specifying "at most". */ |
| 77 | +string describeMaxBound(Function func) { |
| 78 | + if func.hasVarArg() |
| 79 | + then result = "arbitrarily many" |
| 80 | + else result = func.getMaxPositionalArguments().toString() |
| 81 | +} |
| 82 | + |
| 83 | +/** Holds if no way to call `base` would be valid for `sub`. The `msg` applies to the `sub method. */ |
| 84 | +predicate strongSignatureMismatch(Function base, Function sub, string msg) { |
| 85 | + overrides(base, sub) and |
| 86 | + ( |
| 87 | + sub.getMinPositionalArguments() > base.getMaxPositionalArguments() and |
| 88 | + msg = |
| 89 | + "requires " + describeMin(sub) + ", whereas overridden $@ requires " + describeMaxShort(base) + |
| 90 | + "." |
| 91 | + or |
| 92 | + sub.getMaxPositionalArguments() < base.getMinPositionalArguments() and |
| 93 | + msg = |
| 94 | + "requires " + describeMax(sub) + ", whereas overridden $@ requires " + describeMinShort(base) + |
| 95 | + "." |
| 96 | + ) |
| 97 | +} |
| 98 | + |
| 99 | +/** Holds if there may be some ways to call `base` that would not be valid for `sub`. The `msg` applies to the `sub` method. */ |
| 100 | +predicate weakSignatureMismatch(Function base, Function sub, string msg) { |
| 101 | + overrides(base, sub) and |
| 102 | + ( |
| 103 | + sub.getMinPositionalArguments() > base.getMinPositionalArguments() and |
| 104 | + msg = |
| 105 | + "requires " + describeMin(sub) + ", whereas overridden $@ may be called with " + |
| 106 | + base.getMinPositionalArguments().toString() + "." |
| 107 | + or |
| 108 | + sub.getMaxPositionalArguments() < base.getMaxPositionalArguments() and |
| 109 | + msg = |
| 110 | + "requires " + describeMax(sub) + ", whereas overridden $@ may be called with " + |
| 111 | + describeMaxBound(base) + "." |
| 112 | + or |
| 113 | + sub.getMinPositionalArguments() <= base.getMinPositionalArguments() and |
| 114 | + sub.getMaxPositionalArguments() >= base.getMaxPositionalArguments() and |
| 115 | + exists(string arg | |
| 116 | + // TODO: positional-only args not considered |
| 117 | + // e.g. `def foo(x, y, /, z):` has x,y as positional only args, should not be considered as possible kw args |
| 118 | + // However, this likely does not create FPs, as we require a 'witness' call to generate an alert. |
| 119 | + arg = base.getAnArg().getName() and |
| 120 | + not arg = sub.getAnArg().getName() and |
| 121 | + not exists(sub.getKwarg()) and |
| 122 | + msg = "does not accept keyword argument `" + arg + "`, which overridden $@ does." |
| 123 | + ) |
| 124 | + or |
| 125 | + exists(base.getKwarg()) and |
| 126 | + not exists(sub.getKwarg()) and |
| 127 | + msg = "does not accept arbitrary keyword arguments, which overridden $@ does." |
| 128 | + ) |
| 129 | +} |
| 130 | + |
| 131 | +/** Holds if `f` should be ignored for considering signature mismatches. */ |
| 132 | +predicate ignore(Function f) { |
| 133 | + isClassmethod(f) |
| 134 | + or |
| 135 | + exists( |
| 136 | + Function g // other functions with the same name, e.g. @property getters/setters. |
| 137 | + | |
| 138 | + g.getScope() = f.getScope() and |
| 139 | + g.getName() = f.getName() and |
| 140 | + g != f |
| 141 | + ) |
| 142 | +} |
| 143 | + |
| 144 | +/** Gets a function that `call` may resolve to. */ |
| 145 | +Function resolveCall(Call call) { |
| 146 | + exists(DataFlowCall dfc | call = dfc.getNode().(CallNode).getNode() | |
| 147 | + result = viableCallable(dfc).(DataFlowFunction).getScope() |
| 148 | + ) |
| 149 | +} |
| 150 | + |
| 151 | +/** Holds if `call` may resolve to either `base` or `sub`, and `base` is overridden by `sub`. */ |
| 152 | +predicate callViableForEitherOverride(Function base, Function sub, Call call) { |
| 153 | + overrides(base, sub) and |
| 154 | + base = resolveCall(call) and |
| 155 | + sub = resolveCall(call) |
| 156 | +} |
| 157 | + |
| 158 | +/** Holds if either both `base` and `sub` are static methods, or both are not static methods, and `base` is overridden by `sub`. */ |
| 159 | +predicate matchingStatic(Function base, Function sub) { |
| 160 | + overrides(base, sub) and |
| 161 | + ( |
| 162 | + isStaticmethod(base) and |
| 163 | + isStaticmethod(sub) |
| 164 | + or |
| 165 | + not isStaticmethod(base) and |
| 166 | + not isStaticmethod(sub) |
| 167 | + ) |
| 168 | +} |
| 169 | + |
| 170 | +int extraSelfArg(Function func) { if isStaticmethod(func) then result = 0 else result = 1 } |
| 171 | + |
| 172 | +/** Holds if the call `call` matches the signature for `func`. */ |
| 173 | +predicate callMatchesSignature(Function func, Call call) { |
| 174 | + func = resolveCall(call) and |
| 175 | + ( |
| 176 | + // Each parameter of the function is accounted for in the call |
| 177 | + forall(Parameter param, int i | param = func.getArg(i) | |
| 178 | + // self arg |
| 179 | + i = 0 and not isStaticmethod(func) |
| 180 | + or |
| 181 | + // positional arg |
| 182 | + i - extraSelfArg(func) < call.getPositionalArgumentCount() |
| 183 | + or |
| 184 | + // has default |
| 185 | + exists(param.getDefault()) |
| 186 | + or |
| 187 | + // keyword arg |
| 188 | + call.getANamedArgumentName() = param.getName() |
| 189 | + ) |
| 190 | + or |
| 191 | + // arbitrary varargs or kwargs |
| 192 | + exists(call.getStarArg()) |
| 193 | + or |
| 194 | + exists(call.getKwargs()) |
24 | 195 | ) and |
25 | | - not derived.getScope().isSpecialMethod() and |
26 | | - derived.getName() != "__init__" and |
27 | | - derived.isNormalMethod() and |
28 | | - // call to overrides distributed for efficiency |
| 196 | + // No excess parameters |
| 197 | + call.getPositionalArgumentCount() + extraSelfArg(func) <= func.getMaxPositionalArguments() and |
| 198 | + ( |
| 199 | + exists(func.getKwarg()) |
| 200 | + or |
| 201 | + forall(string name | name = call.getANamedArgumentName() | exists(func.getArgByName(name))) |
| 202 | + ) |
| 203 | +} |
| 204 | + |
| 205 | +pragma[nomagic] |
| 206 | +private File getFunctionFile(Function f) { result = f.getLocation().getFile() } |
| 207 | + |
| 208 | +/** Gets a call which matches the signature of `base`, but not of overridden `sub`. */ |
| 209 | +Call getASignatureMismatchWitness(Function base, Function sub) { |
| 210 | + callViableForEitherOverride(base, sub, result) and |
| 211 | + callMatchesSignature(base, result) and |
| 212 | + not callMatchesSignature(sub, result) |
| 213 | +} |
| 214 | + |
| 215 | +pragma[inline] |
| 216 | +string preferredFile(File callFile, Function base, Function sub) { |
| 217 | + if callFile = getFunctionFile(base) |
| 218 | + then result = " A" |
| 219 | + else |
| 220 | + if callFile = getFunctionFile(sub) |
| 221 | + then result = " B" |
| 222 | + else result = callFile.getAbsolutePath() |
| 223 | +} |
| 224 | + |
| 225 | +/** Choose a 'witnessing' call that matches the signature of `base` but not of overridden `sub`. */ |
| 226 | +Call chooseASignatureMismatchWitness(Function base, Function sub) { |
| 227 | + exists(getASignatureMismatchWitness(base, sub)) and |
| 228 | + result = |
| 229 | + min(Call c | |
| 230 | + c = getASignatureMismatchWitness(base, sub) |
| 231 | + | |
| 232 | + c |
| 233 | + order by |
| 234 | + preferredFile(c.getLocation().getFile(), base, sub), c.getLocation().getStartLine(), |
| 235 | + c.getLocation().getStartColumn() |
| 236 | + ) |
| 237 | +} |
| 238 | + |
| 239 | +module CallOption = LocatableOption<Location, Call>; |
| 240 | + |
| 241 | +from Function base, Function sub, string msg, string extraMsg, CallOption::Option call |
| 242 | +where |
| 243 | + not sub.isSpecialMethod() and |
| 244 | + sub.getName() != "__init__" and |
| 245 | + not ignore(sub) and |
| 246 | + not ignore(base) and |
| 247 | + matchingStatic(base, sub) and |
29 | 248 | ( |
30 | | - derived.overrides(base) and derived.minParameters() > base.maxParameters() |
| 249 | + // If we have a witness, alert for a 'weak' mismatch, but prefer the message for a 'strong' mismatch if that holds. |
| 250 | + call.asSome() = chooseASignatureMismatchWitness(base, sub) and |
| 251 | + extraMsg = |
| 252 | + " $@ correctly calls the base method, but does not match the signature of the overriding method." and |
| 253 | + ( |
| 254 | + strongSignatureMismatch(base, sub, msg) |
| 255 | + or |
| 256 | + not strongSignatureMismatch(base, sub, _) and |
| 257 | + weakSignatureMismatch(base, sub, msg) |
| 258 | + ) |
31 | 259 | or |
32 | | - derived.overrides(base) and derived.maxParameters() < base.minParameters() |
| 260 | + // With no witness, only alert for 'strong' mismatches. |
| 261 | + not exists(getASignatureMismatchWitness(base, sub)) and |
| 262 | + call.isNone() and |
| 263 | + strongSignatureMismatch(base, sub, msg) and |
| 264 | + extraMsg = "" |
33 | 265 | ) |
34 | | -select derived, "Overriding method '" + derived.getName() + "' has signature mismatch with $@.", |
35 | | - base, "overridden method" |
| 266 | +select sub, "This method " + msg + extraMsg, base, base.getQualifiedName(), call, "This call" |
0 commit comments