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