packages/sql-catalog/tests/Unit/Core/Analysis/InterpreterTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Analysis;
6
7use PhpParser\Node\Expr;
8use PhpParser\NodeFinder;
9use PHPUnit\Framework\Attributes\CoversClass;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use SqlCatalog\Core\Analysis\Derivation\Solution;
13use SqlCatalog\Core\Analysis\EvaluationBudget;
14use SqlCatalog\Core\Analysis\FunctionScope;
15use SqlCatalog\Core\Analysis\Interpreter;
16use SqlCatalog\Core\Analysis\QueryRecord;
17use SqlCatalog\Core\Analysis\SinkMatcher;
18use SqlCatalog\Core\Analysis\StatementRecorder;
19use SqlCatalog\Core\Analysis\ValueBinder;
20use SqlCatalog\Core\Catalog\CallSite;
21use SqlCatalog\Core\Extension\ExtensionRegistry;
22use SqlCatalog\Core\Php\DeclaredGlobals;
23use SqlCatalog\Core\Php\ProgramIndex;
24use SqlCatalog\Core\Php\ProgramIndexBuilder;
25use SqlCatalog\Core\Php\SourceParser;
26use SqlCatalog\Core\Text\Origin;
27use SqlCatalog\Extension\Pdo\PdoExtension;
28
29#[CoversClass(Interpreter::class)]
30#[UsesClass(QueryRecord::class)]
31#[UsesClass(StatementRecorder::class)]
32#[UsesClass(CallSite::class)]
33#[UsesClass(\SqlCatalog\Core\Analysis\BuiltinCallModel::class)]
34#[UsesClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
35#[UsesClass(\SqlCatalog\Core\Analysis\ConstantReader::class)]
36#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Binding::class)]
37#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
38#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerIndex::class)]
39#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Callers::class)]
40#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
41#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\EntryBinder::class)]
42#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\FreeNames::class)]
43#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\ModifiedNames::class)]
44#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\PropertyWrites::class)]
45#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SliceExecutor::class)]
46#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Arrival::class)]
47#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
48#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BackwardSlicer::class)]
49#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BranchArms::class)]
50#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\LoopPasses::class)]
51#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Pending::class)]
52#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\SliceStep::class)]
53#[UsesClass(Solution::class)]
54#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SourceTree::class)]
55#[UsesClass(EvaluationBudget::class)]
56#[UsesClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
57#[UsesClass(\SqlCatalog\Core\Analysis\ExternalInput::class)]
58#[UsesClass(FunctionScope::class)]
59#[UsesClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
60#[UsesClass(\SqlCatalog\Core\Analysis\SinkFinder::class)]
61#[UsesClass(SinkMatcher::class)]
62#[UsesClass(ValueBinder::class)]
63#[UsesClass(\SqlCatalog\Core\Evaluation\CallResults::class)]
64#[UsesClass(\SqlCatalog\Core\Evaluation\Domain::class)]
65#[UsesClass(\SqlCatalog\Core\Evaluation\Environment::class)]
66#[UsesClass(\SqlCatalog\Core\Evaluation\LiteralTerm::class)]
67#[UsesClass(\SqlCatalog\Core\Evaluation\OpaqueTerm::class)]
68#[UsesClass(PdoExtension::class)]
69#[UsesClass(\SqlCatalog\Core\Extension\SinkSpec::class)]
70#[UsesClass(\SqlCatalog\Core\Php\ClassShape::class)]
71#[UsesClass(DeclaredGlobals::class)]
72#[UsesClass(\SqlCatalog\Core\Php\FunctionShape::class)]
73#[UsesClass(\SqlCatalog\Core\Php\MethodShape::class)]
74#[UsesClass(\SqlCatalog\Core\Php\NodeText::class)]
75#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
76#[UsesClass(\SqlCatalog\Core\Php\ParsedFile::class)]
77#[UsesClass(ProgramIndex::class)]
78#[UsesClass(ProgramIndexBuilder::class)]
79#[UsesClass(SourceParser::class)]
80#[UsesClass(\SqlCatalog\Core\Php\TypeReader::class)]
81#[UsesClass(\SqlCatalog\Core\Text\LiteralText::class)]
82#[UsesClass(\SqlCatalog\Core\Text\TextHole::class)]
83#[UsesClass(\SqlCatalog\Core\Text\TextPattern::class)]
84#[UsesClass(\SqlCatalog\Core\Type\TypeShape::class)]
85#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayEntry::class)]
86#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayTerm::class)]
87#[UsesClass(\SqlCatalog\Core\Evaluation\ObjectTerm::class)]
88#[UsesClass(\SqlCatalog\Extension\Doctrine\DoctrineExtension::class)]
89#[UsesClass(ExtensionRegistry::class)]
90#[UsesClass(\SqlCatalog\Extension\Laravel\LaravelExtension::class)]
91#[UsesClass(\SqlCatalog\Extension\Mysqli\MysqliExtension::class)]
92#[UsesClass(\SqlCatalog\Extension\WordPress\WordPressExtension::class)]
93#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerSet::class)]
94#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
95#[UsesClass(\SqlCatalog\Core\Analysis\Effect\WriteEffects::class)]
96#[UsesClass(\SqlCatalog\Core\Analysis\Effect\ReferenceEffects::class)]
97#[UsesClass(\SqlCatalog\Core\Extension\Model\CallContext::class)]
98final class InterpreterTest extends TestCase
99{
100 public function testAnalyzeReadsEveryStatementFromTheCallThatIssuesIt(): void
101 {
102 $file = (new SourceParser())->parse(
103 't.php',
104 '<?php function f(PDO $d, bool $admin): void { if ($admin) { $t = "admins"; $c = "admin_id"; } else { $t = "users"; $c = "user_id"; } $d->query("SELECT $c FROM $t"); }',
105 );
106 $records = (new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks()))->analyze([$file]);
107
108 self::assertSame(
109 ['SELECT admin_id FROM admins', 'SELECT user_id FROM users'],
110 array_map(static fn (QueryRecord $record): ?string => $record->pattern->text(), $records),
111 );
112 }
113
114 public function testAnalyzeBindsWhatAnExecuteBindsToTheStatementItsHandleCameFrom(): void
115 {
116 $file = (new SourceParser())->parse(
117 't.php',
118 '<?php function f(PDO $d): void { $s = $d->prepare("SELECT * FROM u WHERE id = ?"); $s->execute([7]); }',
119 );
120 $records = (new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks()))->analyze([$file]);
121
122 self::assertCount(1, $records);
123 self::assertSame(7, $records[0]->positional()[0]->soleLiteral()?->value);
124 }
125
126 public function testEvaluatorForWiresAnEvaluatorOverTheFiles(): void
127 {
128 $file = (new SourceParser())->parse('t.php', '<?php function t(): string { return "users"; }');
129 $index = (new ProgramIndexBuilder())->build([$file]);
130 $call = new Expr\FuncCall(new \PhpParser\Node\Name('t'));
131
132 $value = (new Interpreter($index, []))->evaluatorFor([$file])
133 ->evaluate($call, new \SqlCatalog\Core\Evaluation\Environment(), new FunctionScope('t.php'));
134
135 self::assertSame('users', $value->soleLiteral()?->value);
136 }
137
138 public function testDeriverForWiresADeriverOverTheFiles(): void
139 {
140 $file = (new SourceParser())->parse('t.php', '<?php $t = "users"; $x = $t;');
141 $statement = $file->statements[1];
142 self::assertInstanceOf(\PhpParser\Node\Stmt\Expression::class, $statement);
143 self::assertInstanceOf(Expr\Assign::class, $statement->expr);
144
145 $solutions = (new Interpreter(new ProgramIndex(), []))->deriverFor([$file])->solve($statement, [$statement->expr->expr]);
146
147 self::assertSame('users', $solutions[0]->values[0]->soleLiteral()?->value);
148 }
149
150 public function testVisitRecordsAStatementAndHandsBackNothingToBindLater(): void
151 {
152 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->query("SELECT 1"); }');
153 $interpreter = new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks());
154 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
155 self::assertInstanceOf(Expr\MethodCall::class, $call);
156 $recorder = new StatementRecorder();
157
158 $later = $interpreter->visit(
159 $call,
160 $interpreter->deriverFor([$file]),
161 new SinkMatcher((new PdoExtension())->sinks(), new ProgramIndex()),
162 $recorder,
163 new ValueBinder($recorder),
164 );
165
166 self::assertSame([], $later);
167 self::assertSame('SELECT 1', $recorder->records()[0]->pattern->text());
168 }
169
170 public function testVisitHandsBackWhatAnExecuteBinds(): void
171 {
172 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $s = $d->prepare("SELECT ?"); $s->execute([1]); }');
173 $interpreter = new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks());
174 $calls = (new NodeFinder())->findInstanceOf($file->statements, Expr\MethodCall::class);
175 $recorder = new StatementRecorder();
176
177 $later = $interpreter->visit(
178 $calls[1],
179 $interpreter->deriverFor([$file]),
180 new SinkMatcher((new PdoExtension())->sinks(), new ProgramIndex()),
181 $recorder,
182 new ValueBinder($recorder),
183 );
184
185 self::assertCount(1, $later);
186 self::assertSame('pdo.statement.execute', $later[0][0]->id);
187 }
188
189 public function testSinkOfTellsADatabaseCallFromAnotherCallOfTheSameName(): void
190 {
191 $file = (new SourceParser())->parse(
192 't.php',
193 '<?php class Q { function query(string $s): void {} } function f(PDO $d, Q $q): void { $d->query("SELECT 1"); $q->query("x"); }',
194 );
195 $index = (new ProgramIndexBuilder())->build([$file]);
196 $interpreter = new Interpreter($index, (new PdoExtension())->sinks());
197 $deriver = $interpreter->deriverFor([$file]);
198 $matcher = new SinkMatcher((new PdoExtension())->sinks(), $index);
199 $calls = (new NodeFinder())->findInstanceOf($file->statements, Expr\MethodCall::class);
200
201 self::assertSame('pdo.query', $interpreter->sinkOf($calls[0], $deriver, $matcher, $deriver->scopeOf($calls[0]))?->id);
202 self::assertNull($interpreter->sinkOf($calls[1], $deriver, $matcher, $deriver->scopeOf($calls[1])));
203 }
204
205 public function testReceiverOfWorksOutWhatAMethodIsCalledOn(): void
206 {
207 $file = (new SourceParser())->parse('t.php', '<?php function f(): void { global $wpdb; $wpdb->query("SELECT 1"); }');
208 $interpreter = new Interpreter(new ProgramIndex(), [], null, new DeclaredGlobals(['wpdb' => 'wpdb']));
209 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
210 self::assertInstanceOf(Expr\MethodCall::class, $call);
211
212 self::assertSame(['wpdb'], $interpreter->receiverOf($call, $interpreter->deriverFor([$file]))->type()->names);
213 }
214
215 public function testUnidentifiedIsTrueOnlyForATextCarryingCallOnSomethingUnknown(): void
216 {
217 $file = (new SourceParser())->parse('t.php', '<?php function f($x, PDO $d): void { $x->query("SELECT 1"); $d->query("SELECT 1"); $x->execute(); }');
218 $sinks = (new PdoExtension())->sinks();
219 $interpreter = new Interpreter(new ProgramIndex(), $sinks);
220 $deriver = $interpreter->deriverFor([$file]);
221 $matcher = new SinkMatcher($sinks, new ProgramIndex());
222 $calls = (new NodeFinder())->findInstanceOf($file->statements, Expr\MethodCall::class);
223
224 self::assertTrue($interpreter->unidentified($calls[0], $deriver, $matcher));
225 self::assertFalse($interpreter->unidentified($calls[1], $deriver, $matcher));
226 self::assertFalse($interpreter->unidentified($calls[2], $deriver, $matcher));
227 }
228
229 public function testArgumentsOfListsTheExpressionsACallPasses(): void
230 {
231 $file = (new SourceParser())->parse('t.php', '<?php f("a", $b); g(...);');
232 $calls = (new NodeFinder())->findInstanceOf($file->statements, Expr\FuncCall::class);
233 $interpreter = new Interpreter(new ProgramIndex(), []);
234
235 self::assertCount(2, $interpreter->argumentsOf($calls[0]));
236 self::assertSame([], $interpreter->argumentsOf($calls[1]));
237 }
238
239 public function testRecordStatementsRecordsACallNothingCouldBeReadFromAsUnread(): void
240 {
241 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->query("SELECT 1"); }');
242 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
243 self::assertInstanceOf(Expr\MethodCall::class, $call);
244 $recorder = new StatementRecorder();
245 $sink = (new PdoExtension())->sinks()[0];
246
247 (new Interpreter(new ProgramIndex(), []))->recordStatements(
248 $call,
249 $sink,
250 [],
251 new FunctionScope('t.php', 'f'),
252 $recorder,
253 new ValueBinder($recorder),
254 );
255
256 self::assertSame(Origin::Unreached, $recorder->records()[0]->pattern->holes()[0]->origin);
257 }
258
259 public function testRecordUnmatchedFilesTheCallUnderItsOwnSink(): void
260 {
261 $file = (new SourceParser())->parse('t.php', '<?php $x->query("SELECT 1");');
262 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
263 self::assertInstanceOf(Expr\MethodCall::class, $call);
264 $recorder = new StatementRecorder();
265
266 (new Interpreter(new ProgramIndex(), []))->recordUnmatched($call, new FunctionScope('t.php'), $recorder);
267
268 self::assertSame(CallSite::UNMATCHED, $recorder->records()[0]->site->sink);
269 }
270
271 public function testUnreadQuotesTheCallInItsOnlyGap(): void
272 {
273 $file = (new SourceParser())->parse('t.php', '<?php $db->query($sql);');
274 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
275 self::assertInstanceOf(Expr\MethodCall::class, $call);
276
277 $pattern = (new Interpreter(new ProgramIndex(), []))->unread($call);
278
279 self::assertSame('$db->query($sql)', $pattern->holes()[0]->expression);
280 }
281
282 public function testAnalyzeWithATinyBudgetStillReportsTheCall(): void
283 {
284 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $a = 1; $b = 2; $d->query("SELECT 1"); }');
285 $sinks = \SqlCatalog\Facade\Builtins::extensions()->sinksOf(['pdo']);
286
287 $records = (new Interpreter((new ProgramIndexBuilder())->build([$file]), $sinks, new EvaluationBudget(1)))->analyze([$file]);
288
289 self::assertCount(1, $records);
290 }
291
292 public function testAnalyzeSpendsNoMoreThanTheBudgetItIsGiven(): void
293 {
294 $source = '<?php function f(PDO $d): void { $a = "a"; $b = $a . "b"; $c = $b . "c"; $e = $c . "e"; $g = $e . "g"; $d->query("SELECT " . $g); }';
295 $file = (new SourceParser())->parse('t.php', $source);
296 $index = (new ProgramIndexBuilder())->build([$file]);
297
298 $tight = (new Interpreter($index, (new PdoExtension())->sinks(), new EvaluationBudget(5)))->analyze([$file]);
299 $ample = (new Interpreter($index, (new PdoExtension())->sinks()))->analyze([$file]);
300
301 self::assertFalse($tight[0]->pattern->isExact());
302 self::assertSame('SELECT abceg', $ample[0]->pattern->text());
303 }
304
305 public function testAnalyzeGivesEveryCallTheWholeBudget(): void
306 {
307 $body = '(PDO $d): void { $a = "a"; $b = $a . "b"; $c = $b . "c"; $e = $c . "e"; $g = $e . "g"; $d->query("SELECT " . $g); }';
308 $file = (new SourceParser())->parse('t.php', '<?php function f' . $body . ' function h' . $body);
309
310 $records = (new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks(), new EvaluationBudget(10)))->analyze([$file]);
311
312 self::assertSame(['SELECT abceg', 'SELECT abceg'], array_map(static fn (QueryRecord $record): ?string => $record->pattern->text(), $records));
313 self::assertSame(['pdo.query', 'pdo.query'], array_map(static fn (QueryRecord $record): string => $record->site->sink, $records));
314 }
315
316 public function testAnalyzeKeepsWhatEveryCallBinds(): void
317 {
318 $file = (new SourceParser())->parse(
319 't.php',
320 '<?php function f(PDO $d): void { $s = $d->prepare("SELECT :a, :b"); $s->bindValue(":a", 1); $s->bindValue(":b", 2); $s->execute(); }',
321 );
322
323 $records = (new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks()))->analyze([$file]);
324
325 self::assertSame(['a' => 1, 'b' => 2], array_map(
326 static fn (\SqlCatalog\Core\Evaluation\Domain $value): string|int|float|bool|null => $value->soleLiteral()?->value,
327 $records[0]->named(),
328 ));
329 }
330
331 public function testAnalyzeBindsWhatANullsafeExecuteBinds(): void
332 {
333 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $s = $d->prepare("SELECT ?"); $s?->execute([7]); }');
334
335 $records = (new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks()))->analyze([$file]);
336
337 self::assertSame(7, $records[0]->positional()[0]->soleLiteral()?->value);
338 }
339
340 public function testAnalyzeBindsWhatAQueryCallCarriesAlongsideItsStatement(): void
341 {
342 $file = (new SourceParser())->parse('t.php', '<?php function f(mysqli $m): void { $m->execute_query("SELECT ?", [7]); }');
343
344 $records = (new Interpreter((new ProgramIndexBuilder())->build([$file]), (new \SqlCatalog\Extension\Mysqli\MysqliExtension())->sinks()))->analyze([$file]);
345
346 self::assertSame('SELECT ?', $records[0]->pattern->text());
347 self::assertSame(7, $records[0]->positional()[0]->soleLiteral()?->value);
348 }
349
350 public function testAnalyzeRecordsNothingAtACallThatOnlyComposesAStatement(): void
351 {
352 $file = (new SourceParser())->parse('t.php', '<?php function f(): void { global $wpdb; $wpdb->query($wpdb->prepare("SELECT %d", 1)); }');
353 $interpreter = new Interpreter(
354 (new ProgramIndexBuilder())->build([$file]),
355 \SqlCatalog\Facade\Builtins::extensions()->sinksOf(['pdo', 'wordpress']),
356 null,
357 new DeclaredGlobals(['wpdb' => 'wpdb']),
358 );
359
360 $records = $interpreter->analyze([$file]);
361
362 self::assertSame(['wordpress.query'], array_map(static fn (QueryRecord $record): string => $record->site->sink, $records));
363 }
364
365 public function testVisitRecordsNothingForACallWithoutItsStatement(): void
366 {
367 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->query(); }');
368 $interpreter = new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks());
369 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
370 self::assertInstanceOf(Expr\MethodCall::class, $call);
371 $recorder = new StatementRecorder();
372
373 $later = $interpreter->visit(
374 $call,
375 $interpreter->deriverFor([$file]),
376 new SinkMatcher((new PdoExtension())->sinks(), new ProgramIndex()),
377 $recorder,
378 new ValueBinder($recorder),
379 );
380
381 self::assertSame([], $later);
382 self::assertSame([], $recorder->records());
383 }
384
385 public function testVisitRecordsNothingForADatabaseCallThatTakesNoStatement(): void
386 {
387 $file = (new SourceParser())->parse('t.php', '<?php function f(Db $d): void { $d->ping("SELECT 1"); }');
388 $sinks = [new \SqlCatalog\Core\Extension\SinkSpec('db.ping', \SqlCatalog\Core\Extension\SinkCallKind::Method, 'Db', 'ping', \SqlCatalog\Core\Extension\SinkRole::Query)];
389 $interpreter = new Interpreter((new ProgramIndexBuilder())->build([$file]), $sinks);
390 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
391 self::assertInstanceOf(Expr\MethodCall::class, $call);
392 $recorder = new StatementRecorder();
393
394 $interpreter->visit($call, $interpreter->deriverFor([$file]), new SinkMatcher($sinks, new ProgramIndex()), $recorder, new ValueBinder($recorder));
395
396 self::assertSame([], $recorder->records());
397 }
398
399 public function testVisitHandsBackNothingForABindingCallWrittenWithoutAReceiver(): void
400 {
401 $file = (new SourceParser())->parse('t.php', '<?php function f($s): void { stmt_execute($s, [1]); }');
402 $sinks = [new \SqlCatalog\Core\Extension\SinkSpec('db.execute', \SqlCatalog\Core\Extension\SinkCallKind::FunctionCall, null, 'stmt_execute', \SqlCatalog\Core\Extension\SinkRole::Execute, valuesParameter: 1)];
403 $interpreter = new Interpreter(new ProgramIndex(), $sinks);
404 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\FuncCall::class);
405 self::assertInstanceOf(Expr\FuncCall::class, $call);
406 $recorder = new StatementRecorder();
407
408 $later = $interpreter->visit($call, $interpreter->deriverFor([$file]), new SinkMatcher($sinks, new ProgramIndex()), $recorder, new ValueBinder($recorder));
409
410 self::assertSame([], $later);
411 }
412
413 public function testSinkOfReadsANullsafeCallAndAFunctionCall(): void
414 {
415 $file = (new SourceParser())->parse('t.php', '<?php function f(mysqli $m, ?mysqli $n, callable $g): void { $n?->query("SELECT 1"); mysqli_query($m, "SELECT 2"); $g("SELECT 3"); }');
416 $sinks = (new \SqlCatalog\Extension\Mysqli\MysqliExtension())->sinks();
417 $index = (new ProgramIndexBuilder())->build([$file]);
418 $interpreter = new Interpreter($index, $sinks);
419 $deriver = $interpreter->deriverFor([$file]);
420 $matcher = new SinkMatcher($sinks, $index);
421 $calls = (new NodeFinder())->findInstanceOf($file->statements, Expr\CallLike::class);
422
423 self::assertSame(
424 ['mysqli.query', 'mysqli.fn.query', null],
425 array_map(static fn (Expr\CallLike $call): ?string => $interpreter->sinkOf($call, $deriver, $matcher, $deriver->scopeOf($call))?->id, $calls),
426 );
427 }
428
429 public function testSinkOfResolvesAStaticCallOnTheEnclosingClass(): void
430 {
431 $file = (new SourceParser())->parse(
432 't.php',
433 '<?php class DB { static function all(): void { self::select("a"); SELF::select("b"); static::select("c"); } }'
434 . ' class Other { function m(string $c): void { DB::select("d"); $c::select("e"); self::select("f"); } }',
435 );
436 $sinks = [new \SqlCatalog\Core\Extension\SinkSpec('db.select', \SqlCatalog\Core\Extension\SinkCallKind::StaticCall, 'DB', 'select', \SqlCatalog\Core\Extension\SinkRole::Query, sqlParameter: 0)];
437 $index = (new ProgramIndexBuilder())->build([$file]);
438 $interpreter = new Interpreter($index, $sinks);
439 $deriver = $interpreter->deriverFor([$file]);
440 $matcher = new SinkMatcher($sinks, $index);
441 $calls = (new NodeFinder())->findInstanceOf($file->statements, Expr\StaticCall::class);
442
443 self::assertSame(
444 ['db.select', 'db.select', 'db.select', 'db.select', null, null],
445 array_map(static fn (Expr\CallLike $call): ?string => $interpreter->sinkOf($call, $deriver, $matcher, $deriver->scopeOf($call))?->id, $calls),
446 );
447 }
448
449 public function testReceiverOfJoinsEveryWayTheReceiverCanBe(): void
450 {
451 $file = (new SourceParser())->parse('t.php', '<?php class A {} class B {} function f(bool $c): void { if ($c) { $d = new A(); } else { $d = new B(); } $d->query("SELECT 1"); }');
452 $interpreter = new Interpreter((new ProgramIndexBuilder())->build([$file]), (new PdoExtension())->sinks());
453 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
454 self::assertInstanceOf(Expr\MethodCall::class, $call);
455
456 self::assertSame(['A', 'B'], $interpreter->receiverOf($call, $interpreter->deriverFor([$file]))->type()->classNames());
457 }
458
459 public function testUnidentifiedCoversANullsafeCallButNotAFunctionCall(): void
460 {
461 $file = (new SourceParser())->parse('t.php', '<?php function f($x): void { $x?->query("SELECT 1"); query("SELECT 1"); }');
462 $sinks = (new PdoExtension())->sinks();
463 $interpreter = new Interpreter(new ProgramIndex(), $sinks);
464 $deriver = $interpreter->deriverFor([$file]);
465 $matcher = new SinkMatcher($sinks, new ProgramIndex());
466 $nullsafe = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\NullsafeMethodCall::class);
467 self::assertInstanceOf(Expr\NullsafeMethodCall::class, $nullsafe);
468 $function = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\FuncCall::class);
469 self::assertInstanceOf(Expr\FuncCall::class, $function);
470
471 self::assertTrue($interpreter->unidentified($nullsafe, $deriver, $matcher));
472 self::assertFalse($interpreter->unidentified($function, $deriver, $matcher));
473 }
474
475 public function testRecordStatementsSkipsAReadingWithoutTheStatement(): void
476 {
477 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->query("SELECT 1"); }');
478 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
479 self::assertInstanceOf(Expr\MethodCall::class, $call);
480 $recorder = new StatementRecorder();
481
482 (new Interpreter(new ProgramIndex(), []))->recordStatements(
483 $call,
484 (new PdoExtension())->sinks()[0],
485 [new Solution([], []), new Solution([\SqlCatalog\Core\Evaluation\Domain::literal('SELECT 2')], [])],
486 new FunctionScope('t.php', 'f'),
487 $recorder,
488 new ValueBinder($recorder),
489 );
490
491 self::assertSame(['SELECT 2'], array_map(static fn (QueryRecord $record): ?string => $record->pattern->text(), $recorder->records()));
492 }
493
494 public function testRecordStatementsMarksAStatementCombinedWhenEitherTheTextOrTheReadingIs(): void
495 {
496 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->query("SELECT 1"); }');
497 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
498 self::assertInstanceOf(Expr\MethodCall::class, $call);
499 $recorder = new StatementRecorder();
500 $plain = \SqlCatalog\Core\Evaluation\Domain::literal('SELECT 1');
501 $combined = \SqlCatalog\Core\Evaluation\Domain::fromTerms([new \SqlCatalog\Core\Evaluation\LiteralTerm('SELECT 2')], false, true);
502
503 (new Interpreter(new ProgramIndex(), []))->recordStatements(
504 $call,
505 (new PdoExtension())->sinks()[0],
506 [
507 new Solution([$plain], []),
508 new Solution([$plain], [], false, true),
509 new Solution([$combined], []),
510 ],
511 new FunctionScope('t.php', 'f'),
512 $recorder,
513 new ValueBinder($recorder),
514 );
515
516 self::assertSame([false, true, true], array_map(static fn (QueryRecord $record): bool => $record->combined, $recorder->records()));
517 }
518
519 public function testRecordStatementsFilesTheStatementsOfEveryReadingUnderThePreparedHandle(): void
520 {
521 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->prepare("SELECT 1"); }');
522 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
523 self::assertInstanceOf(Expr\MethodCall::class, $call);
524 $recorder = new StatementRecorder();
525
526 (new Interpreter(new ProgramIndex(), []))->recordStatements(
527 $call,
528 (new PdoExtension())->sinks()[2],
529 [
530 new Solution([\SqlCatalog\Core\Evaluation\Domain::literal('SELECT 1')], []),
531 new Solution([\SqlCatalog\Core\Evaluation\Domain::literal('SELECT 2')], []),
532 ],
533 new FunctionScope('t.php', 'f'),
534 $recorder,
535 new ValueBinder($recorder),
536 );
537
538 self::assertSame(
539 ['SELECT 1', 'SELECT 2'],
540 array_map(
541 static fn (QueryRecord $record): ?string => $record->pattern->text(),
542 $recorder->prepared('t.php:' . $call->getStartFilePos() . ':pdo.prepare'),
543 ),
544 );
545 }
546
547 public function testRecordUnmatchedKeysTheCallByItsFileAndOffset(): void
548 {
549 $file = (new SourceParser())->parse('t.php', '<?php $x->query("SELECT 1");');
550 $call = (new NodeFinder())->findFirstInstanceOf($file->statements, Expr\MethodCall::class);
551 self::assertInstanceOf(Expr\MethodCall::class, $call);
552 $recorder = new StatementRecorder();
553
554 (new Interpreter(new ProgramIndex(), []))->recordUnmatched($call, new FunctionScope('t.php'), $recorder);
555
556 self::assertSame('t.php:' . $call->getStartFilePos(), $recorder->records()[0]->siteKey);
557 }
558}
559