@sikkuumiii/defuse 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/README.md +561 -2
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.js +619 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/analyze.d.ts +178 -0
- package/dist/src/core/analyze.js +834 -0
- package/dist/src/core/analyze.js.map +1 -0
- package/dist/src/core/coverage.d.ts +87 -0
- package/dist/src/core/coverage.js +181 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/finding.d.ts +196 -0
- package/dist/src/core/finding.js +178 -0
- package/dist/src/core/finding.js.map +1 -0
- package/dist/src/core/licence.d.ts +50 -0
- package/dist/src/core/licence.js +56 -0
- package/dist/src/core/licence.js.map +1 -0
- package/dist/src/core/paths.d.ts +21 -0
- package/dist/src/core/paths.js +58 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/score.d.ts +137 -0
- package/dist/src/core/score.js +232 -0
- package/dist/src/core/score.js.map +1 -0
- package/dist/src/dast/targets.d.ts +30 -0
- package/dist/src/dast/targets.js +79 -0
- package/dist/src/dast/targets.js.map +1 -0
- package/dist/src/dast/zap.d.ts +42 -0
- package/dist/src/dast/zap.js +184 -0
- package/dist/src/dast/zap.js.map +1 -0
- package/dist/src/engine/scan.d.ts +80 -0
- package/dist/src/engine/scan.js +129 -0
- package/dist/src/engine/scan.js.map +1 -0
- package/dist/src/engine/shapes.d.ts +47 -0
- package/dist/src/engine/shapes.js +328 -0
- package/dist/src/engine/shapes.js.map +1 -0
- package/dist/src/engine/walk.d.ts +60 -0
- package/dist/src/engine/walk.js +156 -0
- package/dist/src/engine/walk.js.map +1 -0
- package/dist/src/parse/astDump.d.ts +31 -0
- package/dist/src/parse/astDump.js +66 -0
- package/dist/src/parse/astDump.js.map +1 -0
- package/dist/src/parse/languages.d.ts +70 -0
- package/dist/src/parse/languages.js +174 -0
- package/dist/src/parse/languages.js.map +1 -0
- package/dist/src/parse/location.d.ts +17 -0
- package/dist/src/parse/location.js +38 -0
- package/dist/src/parse/location.js.map +1 -0
- package/dist/src/parse/node-grammars.d.ts +12 -0
- package/dist/src/parse/node-grammars.js +35 -0
- package/dist/src/parse/node-grammars.js.map +1 -0
- package/dist/src/parse/parser.d.ts +126 -0
- package/dist/src/parse/parser.js +202 -0
- package/dist/src/parse/parser.js.map +1 -0
- package/dist/src/parse/tree-store.d.ts +139 -0
- package/dist/src/parse/tree-store.js +236 -0
- package/dist/src/parse/tree-store.js.map +1 -0
- package/dist/src/platform/dependency-rule.d.ts +27 -0
- package/dist/src/platform/dependency-rule.js +21 -0
- package/dist/src/platform/dependency-rule.js.map +1 -0
- package/dist/src/platform/deps.d.ts +86 -0
- package/dist/src/platform/deps.js +304 -0
- package/dist/src/platform/deps.js.map +1 -0
- package/dist/src/platform/php-facts.d.ts +49 -0
- package/dist/src/platform/php-facts.js +67 -0
- package/dist/src/platform/php-facts.js.map +1 -0
- package/dist/src/platform/runtime-version.d.ts +78 -0
- package/dist/src/platform/runtime-version.js +573 -0
- package/dist/src/platform/runtime-version.js.map +1 -0
- package/dist/src/report/colors.d.ts +50 -0
- package/dist/src/report/colors.js +97 -0
- package/dist/src/report/colors.js.map +1 -0
- package/dist/src/report/coverage-table.d.ts +90 -0
- package/dist/src/report/coverage-table.js +291 -0
- package/dist/src/report/coverage-table.js.map +1 -0
- package/dist/src/report/html-report.d.ts +66 -0
- package/dist/src/report/html-report.js +489 -0
- package/dist/src/report/html-report.js.map +1 -0
- package/dist/src/report/human.d.ts +44 -0
- package/dist/src/report/human.js +575 -0
- package/dist/src/report/human.js.map +1 -0
- package/dist/src/report/json.d.ts +24 -0
- package/dist/src/report/json.js +178 -0
- package/dist/src/report/json.js.map +1 -0
- package/dist/src/report/mark.d.ts +17 -0
- package/dist/src/report/mark.js +39 -0
- package/dist/src/report/mark.js.map +1 -0
- package/dist/src/report/pricing.d.ts +33 -0
- package/dist/src/report/pricing.js +343 -0
- package/dist/src/report/pricing.js.map +1 -0
- package/dist/src/report/sarif.d.ts +39 -0
- package/dist/src/report/sarif.js +219 -0
- package/dist/src/report/sarif.js.map +1 -0
- package/dist/src/report/site.d.ts +87 -0
- package/dist/src/report/site.js +742 -0
- package/dist/src/report/site.js.map +1 -0
- package/dist/src/rules/code-injection.d.ts +47 -0
- package/dist/src/rules/code-injection.js +326 -0
- package/dist/src/rules/code-injection.js.map +1 -0
- package/dist/src/rules/command-injection.d.ts +30 -0
- package/dist/src/rules/command-injection.js +286 -0
- package/dist/src/rules/command-injection.js.map +1 -0
- package/dist/src/rules/contract.d.ts +198 -0
- package/dist/src/rules/contract.js +39 -0
- package/dist/src/rules/contract.js.map +1 -0
- package/dist/src/rules/format-string.d.ts +46 -0
- package/dist/src/rules/format-string.js +146 -0
- package/dist/src/rules/format-string.js.map +1 -0
- package/dist/src/rules/hardcoded-secret.d.ts +38 -0
- package/dist/src/rules/hardcoded-secret.js +440 -0
- package/dist/src/rules/hardcoded-secret.js.map +1 -0
- package/dist/src/rules/lib/guards.d.ts +168 -0
- package/dist/src/rules/lib/guards.js +1179 -0
- package/dist/src/rules/lib/guards.js.map +1 -0
- package/dist/src/rules/lib/html-safety.d.ts +75 -0
- package/dist/src/rules/lib/html-safety.js +755 -0
- package/dist/src/rules/lib/html-safety.js.map +1 -0
- package/dist/src/rules/lib/local-build.d.ts +59 -0
- package/dist/src/rules/lib/local-build.js +320 -0
- package/dist/src/rules/lib/local-build.js.map +1 -0
- package/dist/src/rules/lib/strings.d.ts +208 -0
- package/dist/src/rules/lib/strings.js +1970 -0
- package/dist/src/rules/lib/strings.js.map +1 -0
- package/dist/src/rules/path-traversal.d.ts +33 -0
- package/dist/src/rules/path-traversal.js +96 -0
- package/dist/src/rules/path-traversal.js.map +1 -0
- package/dist/src/rules/registry.d.ts +19 -0
- package/dist/src/rules/registry.js +77 -0
- package/dist/src/rules/registry.js.map +1 -0
- package/dist/src/rules/sql-injection.d.ts +36 -0
- package/dist/src/rules/sql-injection.js +299 -0
- package/dist/src/rules/sql-injection.js.map +1 -0
- package/dist/src/rules/ssrf.d.ts +39 -0
- package/dist/src/rules/ssrf.js +111 -0
- package/dist/src/rules/ssrf.js.map +1 -0
- package/dist/src/rules/unbounded-copy.d.ts +37 -0
- package/dist/src/rules/unbounded-copy.js +147 -0
- package/dist/src/rules/unbounded-copy.js.map +1 -0
- package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
- package/dist/src/rules/unsafe-deserialization.js +276 -0
- package/dist/src/rules/unsafe-deserialization.js.map +1 -0
- package/dist/src/rules/weak-hash.d.ts +37 -0
- package/dist/src/rules/weak-hash.js +197 -0
- package/dist/src/rules/weak-hash.js.map +1 -0
- package/dist/src/rules/xss.d.ts +38 -0
- package/dist/src/rules/xss.js +527 -0
- package/dist/src/rules/xss.js.map +1 -0
- package/dist/src/taint/content-type.d.ts +19 -0
- package/dist/src/taint/content-type.js +20 -0
- package/dist/src/taint/content-type.js.map +1 -0
- package/dist/src/taint/dictionaries.d.ts +23 -0
- package/dist/src/taint/dictionaries.js +1778 -0
- package/dist/src/taint/dictionaries.js.map +1 -0
- package/dist/src/taint/fold.d.ts +59 -0
- package/dist/src/taint/fold.js +1177 -0
- package/dist/src/taint/fold.js.map +1 -0
- package/dist/src/taint/java-scope.d.ts +61 -0
- package/dist/src/taint/java-scope.js +212 -0
- package/dist/src/taint/java-scope.js.map +1 -0
- package/dist/src/taint/local-map.d.ts +79 -0
- package/dist/src/taint/local-map.js +603 -0
- package/dist/src/taint/local-map.js.map +1 -0
- package/dist/src/taint/project.d.ts +172 -0
- package/dist/src/taint/project.js +617 -0
- package/dist/src/taint/project.js.map +1 -0
- package/dist/src/taint/tracer.d.ts +265 -0
- package/dist/src/taint/tracer.js +3817 -0
- package/dist/src/taint/tracer.js.map +1 -0
- package/dist/src/taint/types.d.ts +484 -0
- package/dist/src/taint/types.js +47 -0
- package/dist/src/taint/types.js.map +1 -0
- package/dist/src/taint/visibility.d.ts +25 -0
- package/dist/src/taint/visibility.js +96 -0
- package/dist/src/taint/visibility.js.map +1 -0
- package/docs/examples/defuse.json +20 -0
- package/docs/examples/dvwa-urls.json +14 -0
- package/docs/examples/github-code-scanning.yml +54 -0
- package/docs/examples/tiny.js +4 -0
- package/package.json +73 -4
- package/src/cli.ts +747 -0
- package/src/core/analyze.ts +1072 -0
- package/src/core/coverage.ts +269 -0
- package/src/core/finding.ts +311 -0
- package/src/core/licence.ts +61 -0
- package/src/core/paths.ts +59 -0
- package/src/core/score.ts +353 -0
- package/src/dast/targets.ts +121 -0
- package/src/dast/zap.ts +246 -0
- package/src/engine/scan.ts +200 -0
- package/src/engine/shapes.ts +348 -0
- package/src/engine/walk.ts +196 -0
- package/src/parse/astDump.ts +85 -0
- package/src/parse/languages.ts +232 -0
- package/src/parse/location.ts +44 -0
- package/src/parse/node-grammars.ts +41 -0
- package/src/parse/parser.ts +262 -0
- package/src/parse/tree-store.ts +301 -0
- package/src/platform/dependency-rule.ts +33 -0
- package/src/platform/deps.ts +374 -0
- package/src/platform/php-facts.ts +85 -0
- package/src/platform/runtime-version.ts +710 -0
- package/src/report/colors.ts +107 -0
- package/src/report/coverage-table.ts +359 -0
- package/src/report/html-report.ts +601 -0
- package/src/report/human.ts +811 -0
- package/src/report/json.ts +189 -0
- package/src/report/mark.ts +47 -0
- package/src/report/pricing.ts +374 -0
- package/src/report/sarif.ts +235 -0
- package/src/report/site.ts +760 -0
- package/src/rules/code-injection.ts +346 -0
- package/src/rules/command-injection.ts +296 -0
- package/src/rules/contract.ts +220 -0
- package/src/rules/format-string.ts +158 -0
- package/src/rules/hardcoded-secret.ts +485 -0
- package/src/rules/lib/guards.ts +1235 -0
- package/src/rules/lib/html-safety.ts +793 -0
- package/src/rules/lib/local-build.ts +329 -0
- package/src/rules/lib/strings.ts +2034 -0
- package/src/rules/path-traversal.ts +101 -0
- package/src/rules/registry.ts +90 -0
- package/src/rules/sql-injection.ts +311 -0
- package/src/rules/ssrf.ts +118 -0
- package/src/rules/unbounded-copy.ts +162 -0
- package/src/rules/unsafe-deserialization.ts +291 -0
- package/src/rules/weak-hash.ts +226 -0
- package/src/rules/xss.ts +572 -0
- package/src/taint/content-type.ts +20 -0
- package/src/taint/dictionaries.ts +1855 -0
- package/src/taint/fold.ts +1206 -0
- package/src/taint/java-scope.ts +200 -0
- package/src/taint/local-map.ts +594 -0
- package/src/taint/project.ts +774 -0
- package/src/taint/tracer.ts +4298 -0
- package/src/taint/types.ts +511 -0
- package/src/taint/visibility.ts +100 -0
- package/tsconfig.json +61 -0
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONSTANT FOLDER, SHARED.
|
|
3
|
+
*
|
|
4
|
+
* This lived inside traceFile() for as long as only the tracer needed it. Then
|
|
5
|
+
* the signature rules needed it too: 65 of BenchmarkJava's remaining false
|
|
6
|
+
* positives are SQL strings built from a `bar` that a decidable condition has
|
|
7
|
+
* already set to a literal, and the SQL rule could not see that, because the
|
|
8
|
+
* only thing that knew how to decide a condition was sealed inside the tracer.
|
|
9
|
+
*
|
|
10
|
+
* The alternative was a second, smaller folder in rules/lib. This project has
|
|
11
|
+
* paid for "the same fact written twice" more than once - ASSIGNMENT_NODES below
|
|
12
|
+
* carries a scar from exactly that - so the folder moved out instead, and both
|
|
13
|
+
* callers now use the one copy. Moving it was checked with `npm run identity`,
|
|
14
|
+
* which hashes every finding and every proof step on twelve fixed targets: the
|
|
15
|
+
* move was meant to change nothing, and it had to be shown to change nothing.
|
|
16
|
+
*
|
|
17
|
+
* createFolder() returns a fresh folder per language, per file, so its caches
|
|
18
|
+
* never leak an answer from one file into another.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Node } from 'web-tree-sitter';
|
|
22
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
23
|
+
|
|
24
|
+
/** Whitespace-normalised node text. The tracer has its own copy of this one-liner. */
|
|
25
|
+
function text(node: Node | null | undefined): string {
|
|
26
|
+
return (node?.text ?? '').replace(/\s+/g, ' ').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const assignmentParts = (node: Node): { target: Node; value: Node } | null => {
|
|
30
|
+
const pairs: Array<[string, string]> = [
|
|
31
|
+
['left', 'right'],
|
|
32
|
+
['name', 'value'],
|
|
33
|
+
['key', 'value'],
|
|
34
|
+
// C and C++: `char *user = getenv("X")` is an init_declarator, whose
|
|
35
|
+
// fields are `declarator` and `value` rather than any of the above.
|
|
36
|
+
['declarator', 'value'],
|
|
37
|
+
];
|
|
38
|
+
for (const [targetField, valueField] of pairs) {
|
|
39
|
+
const target = node.childForFieldName(targetField);
|
|
40
|
+
const value = node.childForFieldName(valueField);
|
|
41
|
+
if (target && value) return { target, value };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const ASSIGNMENT_NODES = new Set([
|
|
47
|
+
'assignment_expression', // JS/TS, Java
|
|
48
|
+
'augmented_assignment_expression',
|
|
49
|
+
'variable_declarator', // JS/TS, Java
|
|
50
|
+
'assignment', // Python
|
|
51
|
+
'augmented_assignment',
|
|
52
|
+
'pair',
|
|
53
|
+
'public_field_definition',
|
|
54
|
+
'short_var_declaration', // Go x := ...
|
|
55
|
+
'assignment_statement', // Go x = ...
|
|
56
|
+
'var_spec', // Go var x = ...
|
|
57
|
+
'const_spec', // Go const x = ...
|
|
58
|
+
'property_element', // PHP class property with a default
|
|
59
|
+
/*
|
|
60
|
+
* C/C++ declaration-with-initialiser: `char *user = getenv("X")`.
|
|
61
|
+
*
|
|
62
|
+
* THIS LIST AND THE SHAPE QUERIES IN engine/shapes.ts ARE THE SAME FACT
|
|
63
|
+
* WRITTEN TWICE, and this entry is what that costs. The query knew about
|
|
64
|
+
* init_declarator from the first commit; the tracer's own walk did not, so
|
|
65
|
+
* every C value bound at its declaration read as clean while direct
|
|
66
|
+
* arguments traced perfectly - `printf(getenv("X"))` was flow-verified and
|
|
67
|
+
* `char *u = getenv("X"); printf(u);` was not, which is most of real C.
|
|
68
|
+
*
|
|
69
|
+
* It failed quietly because the shapes that DO work look like success: a
|
|
70
|
+
* plain `p = argv[1]` is an assignment_expression and was always covered.
|
|
71
|
+
*/
|
|
72
|
+
'init_declarator',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export type Folded = number | boolean | undefined;
|
|
76
|
+
|
|
77
|
+
export interface Folder {
|
|
78
|
+
/** Fold an expression to a number or boolean, or undefined when it cannot be decided. */
|
|
79
|
+
readonly foldExpression: (node: Node | null, body: Node, depth: number) => Folded;
|
|
80
|
+
/** True or false when a condition is decidable, undefined when it is not. */
|
|
81
|
+
readonly conditionTruth: (node: Node | null, body: Node) => boolean | undefined;
|
|
82
|
+
/** The three parts of a ternary, whatever the grammar calls them. */
|
|
83
|
+
readonly ternaryParts: (node: Node) => { condition: Node | null; consequence: Node | null; alternative: Node | null };
|
|
84
|
+
/** The arm of an if-statement that certainly does not run, if any. */
|
|
85
|
+
readonly deadBranchOf: (node: Node, body: Node) => Node | null;
|
|
86
|
+
/** Which cases of a switch can run, when the selector and every label fold. */
|
|
87
|
+
readonly switchDecision: (node: Node, body: Node) => SwitchDecision | null;
|
|
88
|
+
/** Whether a name receives any write that is not a plain `name = value`. */
|
|
89
|
+
readonly writtenOtherwise: (name: string, body: Node) => boolean;
|
|
90
|
+
/** How many plain writes a name receives in a function, nested functions included. */
|
|
91
|
+
readonly plainWriteCount: (name: string, body: Node) => number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createFolder(language: LanguageId): Folder {
|
|
95
|
+
/*
|
|
96
|
+
* CONSTANT CONDITIONS, FOLDED - DELIBERATELY BADLY.
|
|
97
|
+
*
|
|
98
|
+
* The engine reads statements in order and does not evaluate conditions, so
|
|
99
|
+
* a value on the losing side of a decision already made at compile time gets
|
|
100
|
+
* carried anyway. That single blind spot was 128 of the 131 false positives
|
|
101
|
+
* still wearing the proven label.
|
|
102
|
+
*
|
|
103
|
+
* int num = 106;
|
|
104
|
+
* bar = (7 * 18) + num > 200 ? "constant" : param;
|
|
105
|
+
*
|
|
106
|
+
* 126 + 106 = 232. The tainted branch cannot run.
|
|
107
|
+
*
|
|
108
|
+
* THIS FOLDER IS SMALL ON PURPOSE. It understands integer and boolean
|
|
109
|
+
* literals, parentheses, negation, arithmetic, comparison and the logical
|
|
110
|
+
* connectives, plus a local variable bound exactly once to something it can
|
|
111
|
+
* already fold. Everything else returns undefined, which means "cannot tell",
|
|
112
|
+
* which means the branch is assumed live and nothing changes.
|
|
113
|
+
*
|
|
114
|
+
* The asymmetry matters. A folder that is too weak costs a false positive
|
|
115
|
+
* that was already there. A folder that is too clever - one that decides
|
|
116
|
+
* `"abc".length() > 2` by modelling String, or evaluates a call - eventually
|
|
117
|
+
* gets a condition wrong in the other direction and DELETES A REAL FINDING.
|
|
118
|
+
* A miss is the failure mode this tool exists to prevent, so every doubt
|
|
119
|
+
* resolves toward keeping the flow.
|
|
120
|
+
*
|
|
121
|
+
* Grammar-agnostic by construction: it reads the `operator` field and the
|
|
122
|
+
* node's own text rather than switching on per-language node names, because
|
|
123
|
+
* eight grammars spell a literal eight ways and a name this list forgot
|
|
124
|
+
* would fail silently toward undefined.
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
const foldLiteral = (raw: string): Folded => {
|
|
128
|
+
const t = raw.trim();
|
|
129
|
+
if (t === 'true' || t === 'True') return true;
|
|
130
|
+
if (t === 'false' || t === 'False') return false;
|
|
131
|
+
/*
|
|
132
|
+
* A leading zero means OCTAL in Java, C, C++, Go, PHP and sloppy JavaScript:
|
|
133
|
+
* `010` is 8, and reading it as ten decided `n < 9` the wrong way round. It
|
|
134
|
+
* is not worth modelling - a leading-zero literal is "cannot tell".
|
|
135
|
+
*/
|
|
136
|
+
if (/^-?0\d/.test(t)) return undefined;
|
|
137
|
+
if (/^-?\d+$/.test(t)) return Number(t);
|
|
138
|
+
if (/^-?\d+[lL]$/.test(t)) return Number(t.slice(0, -1));
|
|
139
|
+
return undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The bare name out of whatever the grammar wrapped it in.
|
|
144
|
+
*
|
|
145
|
+
* `String bar` is Java declaring and naming in one node, so everything up to
|
|
146
|
+
* the last space goes. `$num` is PHP, and the folder reaches the inner `name`
|
|
147
|
+
* node - text `num` - while the assignment target is the outer `variable_name`
|
|
148
|
+
* - text `$num`. Comparing those two raw is a mismatch that looks exactly
|
|
149
|
+
* like "this variable is never assigned", which is a silent undefined.
|
|
150
|
+
*/
|
|
151
|
+
const bareName = (raw: string): string => raw.trim().replace(/^.*\s/, '').replace(/^\$/, '');
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A local name bound exactly once, to something foldable. Two assignments and
|
|
155
|
+
* we stop: the value at this point would depend on order and reachability,
|
|
156
|
+
* which is the very thing we are not modelling.
|
|
157
|
+
*/
|
|
158
|
+
/*
|
|
159
|
+
* One walk per name per function, not one per mention.
|
|
160
|
+
*
|
|
161
|
+
* Keyed on the body's SOURCE SPAN rather than on the node, because the
|
|
162
|
+
* tree-sitter binding hands back a fresh wrapper object every time a child is
|
|
163
|
+
* asked for - a Map keyed by Node would miss on every lookup and quietly
|
|
164
|
+
* degrade into no cache at all, which is the same mistake that once made the
|
|
165
|
+
* dead-branch skip compare two identical nodes and decide they differed.
|
|
166
|
+
*/
|
|
167
|
+
const constantCache = new Map<string, Map<string, Folded>>();
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
* THE CYCLE GUARD, AND THE DEPTH THAT USED TO RESET TO ZERO.
|
|
171
|
+
*
|
|
172
|
+
* Resolving a name folds whatever it was assigned, and folding that can hit
|
|
173
|
+
* another name. `x = y + 1; y = x + 1` is a loop, and until now the recursion
|
|
174
|
+
* had nothing to stop it: foldExpression carries a depth cap of 12, but the
|
|
175
|
+
* call BACK into it from here passed 0, so every hop through a name reset the
|
|
176
|
+
* budget and the cap could never be reached.
|
|
177
|
+
*
|
|
178
|
+
* That was survivable while the folder barely reached names at all. Reading
|
|
179
|
+
* operators off the shape made it reach them constantly, and DVWA's packed
|
|
180
|
+
* sha256 - one 10,417-character line, hundreds of one-letter names assigned
|
|
181
|
+
* from each other - turned it into an out-of-memory on a 10KB file.
|
|
182
|
+
*
|
|
183
|
+
* Two changes, and each would have been enough on its own: the depth now
|
|
184
|
+
* threads through instead of resetting, and a name currently being resolved
|
|
185
|
+
* answers "cannot tell" if it is asked about again. Two, because the depth is
|
|
186
|
+
* the principle and the cycle guard is the proof - a budget that merely runs
|
|
187
|
+
* out still does the work first, and a loop should cost nothing at all.
|
|
188
|
+
*/
|
|
189
|
+
const resolving = new Set<string>();
|
|
190
|
+
|
|
191
|
+
/*
|
|
192
|
+
* "BOUND EXACTLY ONCE" WAS NOT ENOUGH, AND IT WAS DELETING REAL FINDINGS.
|
|
193
|
+
*
|
|
194
|
+
* The rule used to be: find every `=` to this name in the function, and if
|
|
195
|
+
* there is exactly one, the name IS that value. Probed before the switch
|
|
196
|
+
* folding was built on top of it, that rule was wrong in every language here,
|
|
197
|
+
* in ordinary code, and wrong in the dangerous direction:
|
|
198
|
+
*
|
|
199
|
+
* void f(..., int num) { if (x) num = 500; bar = num > 200 ? "safe" : param; }
|
|
200
|
+
* for (int i = 0; i < 10; i++) { bar = i > 5 ? param : "safe"; }
|
|
201
|
+
* n := 0; fmt.Sscan(s, &n); if n > 5 { bar = param }
|
|
202
|
+
*
|
|
203
|
+
* One `=` each - so `num` was 500, `i` was 0 and `n` was 0, a branch that
|
|
204
|
+
* really runs was declared dead, a real SQL injection went unreported, and
|
|
205
|
+
* the SQL rule printed a receipt calling the line PROVABLY CONSTANT. A
|
|
206
|
+
* parameter already holds the caller's value before any `=`; `i++` and `&n`
|
|
207
|
+
* write without an `=`; a field is not this function's to fix.
|
|
208
|
+
*
|
|
209
|
+
* So the single write now has to be one that CANNOT be bypassed:
|
|
210
|
+
*
|
|
211
|
+
* - a DECLARATION with its value (Java, C, C++, Go, JS let/const), and the
|
|
212
|
+
* use inside that declaration's block, after it. Block scoping is what
|
|
213
|
+
* makes it sound: nothing outside the block can name it, and nothing
|
|
214
|
+
* inside can read it before the declaration runs.
|
|
215
|
+
* - in Python, PHP and a JavaScript `var` there are no block-scoped
|
|
216
|
+
* declarations, so the write must sit at the TOP LEVEL of the function
|
|
217
|
+
* body - not inside an if, a loop, a try - and before the read. A write
|
|
218
|
+
* there cannot be skipped, so it replaces a parameter's value outright.
|
|
219
|
+
* - and nothing else may write to it: no `++`/`--`, no address taken, no
|
|
220
|
+
* loop or `with`/`as` binding, no `global`, and - in PHP and C++, where a
|
|
221
|
+
* call site does not show that an argument is passed by reference - not
|
|
222
|
+
* handed to any call at all.
|
|
223
|
+
*
|
|
224
|
+
* Anything else is "cannot tell". SwitchOnConstant.java and folder-soundness.*
|
|
225
|
+
* are the fence; every case in them was a missed vulnerability.
|
|
226
|
+
*/
|
|
227
|
+
const wordIn = (haystack: string, name: string): boolean =>
|
|
228
|
+
new RegExp(`(^|[^\\w$])\\$?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`).test(haystack);
|
|
229
|
+
|
|
230
|
+
/** Node types that write to a name without an `=`, and the field that names it. */
|
|
231
|
+
const OTHER_BINDERS: ReadonlyArray<[string, string | null]> = [
|
|
232
|
+
['for_statement', 'left'], // Python for i in ...
|
|
233
|
+
['for_in_statement', 'left'], // JS for (x of xs)
|
|
234
|
+
['enhanced_for_statement', 'name'], // Java for (String s : xs)
|
|
235
|
+
['range_clause', 'left'], // Go for i := range xs
|
|
236
|
+
['for_range_loop', 'declarator'], // C++ for (auto x : xs)
|
|
237
|
+
['named_expression', 'name'], // Python (x := ...)
|
|
238
|
+
['as_pattern_target', null], // Python with ... as x, except E as x
|
|
239
|
+
['catch_formal_parameter', 'name'], // Java
|
|
240
|
+
['catch_clause', 'parameter'], // JS
|
|
241
|
+
['global_statement', null], // Python
|
|
242
|
+
['nonlocal_statement', null], // Python
|
|
243
|
+
['global_declaration', null], // PHP
|
|
244
|
+
['function_static_declaration', null], // PHP
|
|
245
|
+
['static_variable_declaration', null], // PHP
|
|
246
|
+
['foreach_statement', null], // PHP checked below: the part after `as`
|
|
247
|
+
['case_pattern', null], // Python match x: case n: - a bare name CAPTURES
|
|
248
|
+
];
|
|
249
|
+
const INCREMENT_NODES = new Set(['update_expression', 'inc_statement', 'dec_statement']);
|
|
250
|
+
|
|
251
|
+
const BINDER_FIELD = new Map(OTHER_BINDERS);
|
|
252
|
+
/** Where an address is taken, in the grammars that can take one. */
|
|
253
|
+
const ADDRESS_OF = new Set([
|
|
254
|
+
'pointer_expression', // C, C++ &n
|
|
255
|
+
'unary_expression', // Go &n
|
|
256
|
+
'by_ref', // PHP foreach ($a as &$v), function f(&$x)
|
|
257
|
+
'reference_assignment_expression', // PHP $y = &$n
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
/*
|
|
261
|
+
* Checked on every node of the function for every name the folder asks
|
|
262
|
+
* about, so it must decide by node TYPE before it reads any text: reading
|
|
263
|
+
* the text of a large node copies it out of the parser, and doing that for
|
|
264
|
+
* each node made a BenchmarkJava scan measurably slower.
|
|
265
|
+
*/
|
|
266
|
+
const writesWithoutAssignment = (node: Node, name: string): boolean => {
|
|
267
|
+
const type = node.type;
|
|
268
|
+
if (INCREMENT_NODES.has(type)) return wordIn(node.text, name);
|
|
269
|
+
// `&n` - C, C++, Go - and PHP's `&$n`: whoever holds the address can write.
|
|
270
|
+
if (ADDRESS_OF.has(type)) {
|
|
271
|
+
// Comments and parentheses are dropped first: `&(n)` and `&/*out*/n` are
|
|
272
|
+
// both the address of n.
|
|
273
|
+
const t = node.text;
|
|
274
|
+
if (t.length > 200) return false;
|
|
275
|
+
const flat = t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/[()\s]/g, '');
|
|
276
|
+
return new RegExp(`&\\$?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`).test(flat);
|
|
277
|
+
}
|
|
278
|
+
// Go: `n.Set(s)` takes &n implicitly when Set has a pointer receiver.
|
|
279
|
+
if (language === 'go' && type === 'call_expression') {
|
|
280
|
+
const fn = node.childForFieldName('function');
|
|
281
|
+
if (fn?.type === 'selector_expression' && bareName(fn.childForFieldName('operand')?.text ?? '') === name) return true;
|
|
282
|
+
}
|
|
283
|
+
if (language === 'c' || language === 'cpp') {
|
|
284
|
+
// A macro can assign to its argument - `SET_INT(n, s)` - and nothing at the
|
|
285
|
+
// call says so. Upper-case callees are read as macros, by convention.
|
|
286
|
+
if (type === 'call_expression' && /^[A-Z][A-Z0-9_]*$/.test(node.childForFieldName('function')?.text ?? '')) {
|
|
287
|
+
const args = node.childForFieldName('arguments');
|
|
288
|
+
if (args?.namedChildren.some((c) => !!c && bareName(c.text.replace(/[()\s]/g, '')) === name)) return true;
|
|
289
|
+
}
|
|
290
|
+
// `#define n ...` makes every later `n` something else entirely.
|
|
291
|
+
if ((type === 'preproc_def' || type === 'preproc_function_def') && node.childForFieldName('name')?.text === name) {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const field = BINDER_FIELD.get(type);
|
|
296
|
+
if (field !== undefined) {
|
|
297
|
+
if (type === 'foreach_statement') {
|
|
298
|
+
const afterAs = node.text.split(/\bas\b/)[1]?.split(')')[0] ?? '';
|
|
299
|
+
return wordIn(afterAs, name);
|
|
300
|
+
}
|
|
301
|
+
const binder = field ? node.childForFieldName(field) : node;
|
|
302
|
+
return !!binder && wordIn(binder.text, name);
|
|
303
|
+
}
|
|
304
|
+
// PHP and C++ pass by reference invisibly: `preg_match($re, $s, $n)` and
|
|
305
|
+
// `void f(int &x); f(n);` both write to n with nothing at the call site.
|
|
306
|
+
if (language === 'php' && type === 'argument') return bareName(node.text) === name;
|
|
307
|
+
if (language === 'cpp') {
|
|
308
|
+
if (type === 'argument_list') return node.namedChildren.some((c) => !!c && bareName(c.text) === name);
|
|
309
|
+
// `std::cin >> n`, and `int &r = n` - a second name for the same storage.
|
|
310
|
+
if (type === 'binary_expression' && node.childForFieldName('operator')?.text === '>>' &&
|
|
311
|
+
bareName(node.childForFieldName('right')?.text ?? '') === name) return true;
|
|
312
|
+
if (type === 'init_declarator' && node.childForFieldName('declarator')?.type === 'reference_declarator' &&
|
|
313
|
+
bareName(node.childForFieldName('value')?.text ?? '') === name) return true;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The region a single write governs, or null when that write can be bypassed.
|
|
320
|
+
* See the note above: a block-scoped declaration governs its block; a plain
|
|
321
|
+
* write governs the function only when it sits at the top of the body.
|
|
322
|
+
*/
|
|
323
|
+
/*
|
|
324
|
+
* WHAT RANGE A DECLARED TYPE CAN HOLD, because arithmetic does not wrap here
|
|
325
|
+
* and does in the program. `int t = 30 * 24 * 60 * 60 * 1000` is NEGATIVE in
|
|
326
|
+
* Java; `unsigned n = 0 - 1` is huge in C; `signed char c = 200` is -56. A
|
|
327
|
+
* value outside what its type holds is "cannot tell". Null means the type is
|
|
328
|
+
* not one whose numbers are modelled at all - unsigned, floating point, a
|
|
329
|
+
* typedef, a class - and then no number bound to it is trusted.
|
|
330
|
+
*/
|
|
331
|
+
const INT32: readonly [number, number] = [-2147483648, 2147483647];
|
|
332
|
+
const INT16: readonly [number, number] = [-32768, 32767];
|
|
333
|
+
const SAFE: readonly [number, number] = [-(2 ** 53), 2 ** 53];
|
|
334
|
+
const rangeOfType = (typeText: string | undefined): readonly [number, number] | null => {
|
|
335
|
+
const t = (typeText ?? '').replace(/\b(?:const|constexpr|static|final|volatile|register|signed)\b/g, ' ').replace(/\s+/g, ' ').trim();
|
|
336
|
+
if (language === 'java') {
|
|
337
|
+
if (t === 'int' || t === 'long' || t === 'var') return INT32;
|
|
338
|
+
if (t === 'short') return INT16;
|
|
339
|
+
if (t === 'byte') return [-128, 127];
|
|
340
|
+
if (t === 'char') return [0, 65535];
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
if (language === 'c' || language === 'cpp') {
|
|
344
|
+
if (/^(?:int|long|long int|long long|long long int|short int|)$/.test(t)) return t === '' ? INT32 : INT32;
|
|
345
|
+
if (t === 'short') return INT16;
|
|
346
|
+
// Plain `char` may be signed or unsigned; 0-127 is the same either way.
|
|
347
|
+
if (t === 'char') return [0, 127];
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
if (language === 'go') {
|
|
351
|
+
if (t === '' || t === 'int' || t === 'int32' || t === 'int64' || t === 'rune') return INT32;
|
|
352
|
+
if (t === 'int16') return INT16;
|
|
353
|
+
if (t === 'int8') return [-128, 127];
|
|
354
|
+
if (t === 'byte' || t === 'uint8') return [0, 255];
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return SAFE;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const regionOfWrite = (write: Node, body: Node): { region: Node; range: readonly [number, number] | null } | null => {
|
|
361
|
+
const found = regionNodeOfWrite(write, body);
|
|
362
|
+
if (!found) return null;
|
|
363
|
+
const parent = write.parent;
|
|
364
|
+
let typeText: string | undefined;
|
|
365
|
+
if (language === 'java' || language === 'c' || language === 'cpp') {
|
|
366
|
+
typeText = parent?.childForFieldName('type')?.text;
|
|
367
|
+
// C++ references and aliasing mean a mutable local cannot be read from
|
|
368
|
+
// the text alone - `int &r{n}` writes n with no `n` in sight. Only a
|
|
369
|
+
// `const` or `constexpr` local is a constant in C++.
|
|
370
|
+
if (language === 'cpp' && !parent?.namedChildren.some((c) => c?.type === 'type_qualifier' && /^(?:const|constexpr)$/.test(c.text))) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
// An unsigned or sized declaration keeps its qualifiers in the type node.
|
|
374
|
+
if (language !== 'java') {
|
|
375
|
+
const qualifiers = (parent?.namedChildren ?? []).filter((c) => c?.type === 'type_qualifier' || c?.type === 'storage_class_specifier').map((c) => c?.text ?? '');
|
|
376
|
+
typeText = `${qualifiers.join(' ')} ${typeText ?? ''}`;
|
|
377
|
+
}
|
|
378
|
+
} else if (language === 'go' && write.type === 'var_spec') {
|
|
379
|
+
typeText = write.childForFieldName('type')?.text;
|
|
380
|
+
}
|
|
381
|
+
return { region: found, range: rangeOfType(typeText) };
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const regionNodeOfWrite = (write: Node, body: Node): Node | null => {
|
|
385
|
+
const parent = write.parent;
|
|
386
|
+
if (!parent) return null;
|
|
387
|
+
const same = (a: Node | null, b: Node): boolean =>
|
|
388
|
+
!!a && a.startIndex === b.startIndex && a.endIndex === b.endIndex;
|
|
389
|
+
// The tracer hands over a function's BODY; the constant proof hands over the
|
|
390
|
+
// FUNCTION. Both mean the same scope, and the top-level tests below need the
|
|
391
|
+
// block either way.
|
|
392
|
+
const block = body.childForFieldName('body') ?? body;
|
|
393
|
+
|
|
394
|
+
if (language === 'java' && write.type === 'variable_declarator' && parent.type === 'local_variable_declaration') {
|
|
395
|
+
return parent.parent;
|
|
396
|
+
}
|
|
397
|
+
if ((language === 'c' || language === 'cpp') && write.type === 'init_declarator' && parent.type === 'declaration') {
|
|
398
|
+
return parent.parent;
|
|
399
|
+
}
|
|
400
|
+
if (language === 'go' && (write.type === 'short_var_declaration' || write.type === 'var_spec' || write.type === 'const_spec')) {
|
|
401
|
+
const holder = write.type === 'short_var_declaration' ? parent : parent.parent;
|
|
402
|
+
return holder ?? null;
|
|
403
|
+
}
|
|
404
|
+
if ((language === 'javascript' || language === 'typescript') && write.type === 'variable_declarator') {
|
|
405
|
+
if (parent.type === 'lexical_declaration') return parent.parent;
|
|
406
|
+
// `var` is function-scoped and hoisted: only the top of the body is safe.
|
|
407
|
+
if (parent.type === 'variable_declaration' && same(parent.parent, block)) return block;
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
/*
|
|
411
|
+
* No parameter check is needed here, and that is not an oversight: a write
|
|
412
|
+
* at the top of the body runs before any read after it, so it replaces the
|
|
413
|
+
* caller's value rather than competing with it. What a parameter breaks is
|
|
414
|
+
* a write that might NOT run - and that is exactly what "top level" rules out.
|
|
415
|
+
*/
|
|
416
|
+
if ((language === 'python' || language === 'php') && parent.type === 'expression_statement' && same(parent.parent, block)) {
|
|
417
|
+
return block;
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
interface WriteFacts {
|
|
423
|
+
readonly write: Node;
|
|
424
|
+
readonly value: Node;
|
|
425
|
+
readonly region: Node;
|
|
426
|
+
readonly range: readonly [number, number] | null;
|
|
427
|
+
}
|
|
428
|
+
interface WriteSurvey {
|
|
429
|
+
/** Plain writes whose target IS the name. */
|
|
430
|
+
readonly writes: ReadonlyArray<{ write: Node; value: Node }>;
|
|
431
|
+
/** Any write the name receives some other way - or a function where that cannot be ruled out. */
|
|
432
|
+
readonly otherWrite: boolean;
|
|
433
|
+
}
|
|
434
|
+
const surveyCache = new Map<string, WriteSurvey>();
|
|
435
|
+
|
|
436
|
+
/*
|
|
437
|
+
* NAMES THAT CANNOT BE READ FROM THE TEXT AT ALL. `with` and `eval` in
|
|
438
|
+
* JavaScript, `$$name`, `${...}`, extract() and eval() in PHP, exec() and
|
|
439
|
+
* locals() in Python: any of them in a function can write to any local in
|
|
440
|
+
* it, under a name that appears nowhere. One of them present, and no name in
|
|
441
|
+
* that function is a constant.
|
|
442
|
+
*/
|
|
443
|
+
const dynamicScopeCache = new Map<string, boolean>();
|
|
444
|
+
const DYNAMIC_CALLS = new Set(['eval', 'exec', 'extract', 'parse_str', 'locals', 'globals', 'vars']);
|
|
445
|
+
const hasDynamicScope = (body: Node): boolean => {
|
|
446
|
+
const key = `${body.startIndex}:${body.endIndex}`;
|
|
447
|
+
const cached = dynamicScopeCache.get(key);
|
|
448
|
+
if (cached !== undefined) return cached;
|
|
449
|
+
/*
|
|
450
|
+
* A function the grammar could not fully parse may hide a write inside the
|
|
451
|
+
* part it gave up on - Java's grammar reads `(n) = x;` as an error, so that
|
|
452
|
+
* write was invisible and the declaration looked like the only one.
|
|
453
|
+
*/
|
|
454
|
+
let found = body.hasError;
|
|
455
|
+
let walked = 0;
|
|
456
|
+
/*
|
|
457
|
+
* PYTHON FUNCTIONS ARE THE EXCEPTION. Since Python 3, exec/eval/locals()
|
|
458
|
+
* inside a function work on a COPY of its locals - they cannot rebind one
|
|
459
|
+
* (PEP 667 made that the language's word in 3.13; earlier releases behave
|
|
460
|
+
* the same without tracing hooks). So inside a function they only count
|
|
461
|
+
* when a `global`/`nonlocal` line lets a name escape to a scope exec CAN
|
|
462
|
+
* write. Module and class bodies are real dicts, and keep the old answer.
|
|
463
|
+
* Refusing here had stopped BenchmarkPython's `num = 86; if 7*42 - num > 200`
|
|
464
|
+
* from ever being decided in a function that went on to call exec().
|
|
465
|
+
*/
|
|
466
|
+
const pyFunction = language === 'python' && (body.type === 'function_definition' || body.parent?.type === 'function_definition');
|
|
467
|
+
let pyDynamicCall = false;
|
|
468
|
+
let pyEscape = false;
|
|
469
|
+
const visit = (n: Node): void => {
|
|
470
|
+
if (found || ++walked > 20000) {
|
|
471
|
+
found = true;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const type = n.type;
|
|
475
|
+
if (type === 'with_statement' && (language === 'javascript' || language === 'typescript')) found = true;
|
|
476
|
+
else if (type === 'dynamic_variable_name') found = true;
|
|
477
|
+
// C lets `goto` jump past `int n = 0;` into its block, leaving n unset.
|
|
478
|
+
// (C++ refuses to compile that, so only C gives up here.)
|
|
479
|
+
else if (type === 'goto_statement' && language === 'c') found = true;
|
|
480
|
+
else if (type === 'identifier' && n.text === 'arguments' && (language === 'javascript' || language === 'typescript')) found = true;
|
|
481
|
+
else if (pyFunction && (type === 'global_statement' || type === 'nonlocal_statement')) pyEscape = true;
|
|
482
|
+
else if ((type === 'call_expression' || type === 'call' || type === 'function_call_expression') &&
|
|
483
|
+
DYNAMIC_CALLS.has((n.childForFieldName('function')?.text ?? '').replace(/^\\/, ''))) {
|
|
484
|
+
if (pyFunction) pyDynamicCall = true;
|
|
485
|
+
else found = true;
|
|
486
|
+
}
|
|
487
|
+
if (!found) for (const c of n.namedChildren) if (c) visit(c);
|
|
488
|
+
};
|
|
489
|
+
visit(body);
|
|
490
|
+
if (pyDynamicCall && pyEscape) found = true;
|
|
491
|
+
dynamicScopeCache.set(key, found);
|
|
492
|
+
return found;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/** Every write to `name` in `body`, and whether any came some other way. */
|
|
496
|
+
const survey = (name: string, body: Node): WriteSurvey => {
|
|
497
|
+
const key = `${body.startIndex}:${body.endIndex}#${name}`;
|
|
498
|
+
const cached = surveyCache.get(key);
|
|
499
|
+
if (cached) return cached;
|
|
500
|
+
|
|
501
|
+
const writes: Array<{ write: Node; value: Node }> = [];
|
|
502
|
+
let otherWrite = hasDynamicScope(body);
|
|
503
|
+
let walked = 0;
|
|
504
|
+
const visit = (node: Node, walkDepth: number): void => {
|
|
505
|
+
if (otherWrite) return;
|
|
506
|
+
// Out of budget is "cannot tell", never "nothing more to see": a second
|
|
507
|
+
// write in the 20th arm of an else-if chain is still a second write.
|
|
508
|
+
if (walkDepth > 400 || ++walked > 20000) {
|
|
509
|
+
otherWrite = true;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
/*
|
|
513
|
+
* ASSIGNMENT_NODES first, and this is not belt-and-braces.
|
|
514
|
+
*
|
|
515
|
+
* assignmentParts() looks for a `left`/`right` field pair, and a
|
|
516
|
+
* comparison has exactly that - so `num > 200` was read as an assignment
|
|
517
|
+
* of 200 to num. The folder then saw two assignments to `num` and gave
|
|
518
|
+
* up, which is why every condition with a variable in it silently failed
|
|
519
|
+
* to fold. The real tracer never hit this because it only ever calls
|
|
520
|
+
* assignmentParts on nodes it has already checked.
|
|
521
|
+
*/
|
|
522
|
+
const parts = ASSIGNMENT_NODES.has(node.type) ? assignmentParts(node) : null;
|
|
523
|
+
if (parts) {
|
|
524
|
+
const target = text(parts.target);
|
|
525
|
+
if (bareName(target) === name && !/[()[\]{},]/.test(target)) {
|
|
526
|
+
writes.push({ write: node, value: parts.value });
|
|
527
|
+
// `n += 1` and Python's `n += 1` are writes the constant proof must see.
|
|
528
|
+
if (/augmented/.test(node.type) || (node.childForFieldName('operator')?.text ?? '=') !== '=') otherWrite = true;
|
|
529
|
+
} else if (wordIn(target, name)) {
|
|
530
|
+
/*
|
|
531
|
+
* The name is PART of what is written: `(n) = x`, `n, err := f()`,
|
|
532
|
+
* `[n] = [x]`, `list($n) = ...`, `{ limit } = req.query`. None of those
|
|
533
|
+
* was counted before, so the one plain `n := 0` looked like the only
|
|
534
|
+
* write there was.
|
|
535
|
+
*/
|
|
536
|
+
otherWrite = true;
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
} else if (writesWithoutAssignment(node, name)) {
|
|
540
|
+
otherWrite = true;
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
for (const child of node.namedChildren) if (child) visit(child, walkDepth + 1);
|
|
544
|
+
};
|
|
545
|
+
visit(body, 0);
|
|
546
|
+
|
|
547
|
+
const result = { writes, otherWrite };
|
|
548
|
+
surveyCache.set(key, result);
|
|
549
|
+
return result;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const writeCache = new Map<string, WriteFacts | null>();
|
|
553
|
+
|
|
554
|
+
/** The one write that decides `name` in `body`, if there is exactly one sound one. */
|
|
555
|
+
const soleWrite = (name: string, body: Node): WriteFacts | null => {
|
|
556
|
+
const key = `${body.startIndex}:${body.endIndex}#${name}`;
|
|
557
|
+
if (writeCache.has(key)) return writeCache.get(key) ?? null;
|
|
558
|
+
const { writes, otherWrite } = survey(name, body);
|
|
559
|
+
const only = writes.length === 1 && !otherWrite ? writes[0] : undefined;
|
|
560
|
+
const found = only ? regionOfWrite(only.write, body) : null;
|
|
561
|
+
const facts = only && found ? { write: only.write, value: only.value, region: found.region, range: found.range } : null;
|
|
562
|
+
writeCache.set(key, facts);
|
|
563
|
+
return facts;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/** For the constant proof: does `name` receive any write that is not a plain `name = value`? */
|
|
567
|
+
const writtenOtherwise = (name: string, body: Node): boolean => survey(bareName(name), body).otherWrite;
|
|
568
|
+
|
|
569
|
+
/** Every plain write to `name` in `body`, nested functions included. */
|
|
570
|
+
const plainWriteCount = (name: string, body: Node): number => survey(bareName(name), body).writes.length;
|
|
571
|
+
|
|
572
|
+
/** Does the sole write certainly run before `use`, and govern it? */
|
|
573
|
+
const writeReaches = (facts: WriteFacts, use: Node | null): boolean =>
|
|
574
|
+
!!use &&
|
|
575
|
+
facts.write.endIndex <= use.startIndex &&
|
|
576
|
+
use.startIndex >= facts.region.startIndex &&
|
|
577
|
+
use.endIndex <= facts.region.endIndex;
|
|
578
|
+
|
|
579
|
+
const constantOfName = (rawName: string, body: Node, depth: number, use: Node | null): Folded => {
|
|
580
|
+
const name = bareName(rawName);
|
|
581
|
+
const facts = soleWrite(name, body);
|
|
582
|
+
if (!facts || !writeReaches(facts, use)) return undefined;
|
|
583
|
+
|
|
584
|
+
const scopeKey = `${body.startIndex}:${body.endIndex}`;
|
|
585
|
+
let perScope = constantCache.get(scopeKey);
|
|
586
|
+
if (!perScope) {
|
|
587
|
+
perScope = new Map<string, Folded>();
|
|
588
|
+
constantCache.set(scopeKey, perScope);
|
|
589
|
+
}
|
|
590
|
+
if (perScope.has(name)) return perScope.get(name);
|
|
591
|
+
|
|
592
|
+
const cycleKey = `${scopeKey}#${name}`;
|
|
593
|
+
if (resolving.has(cycleKey)) return undefined;
|
|
594
|
+
resolving.add(cycleKey);
|
|
595
|
+
// `depth` is how many names deep the resolution itself is.
|
|
596
|
+
let answer = foldExpression(facts.value, body, depth + 1);
|
|
597
|
+
resolving.delete(cycleKey);
|
|
598
|
+
if (typeof answer === 'number' && (!facts.range || answer < facts.range[0] || answer > facts.range[1])) {
|
|
599
|
+
answer = undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/*
|
|
603
|
+
* Cached even when the answer was cut short by the depth budget. A budget
|
|
604
|
+
* that ran out always answers undefined, which means "cannot tell", which
|
|
605
|
+
* keeps the flow - so the worst a stale one can do is leave a false
|
|
606
|
+
* positive that was already there. Caching a NARROWER answer would be the
|
|
607
|
+
* dangerous direction, and undefined is never narrower.
|
|
608
|
+
*/
|
|
609
|
+
perScope.set(name, answer);
|
|
610
|
+
return answer;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
/*
|
|
614
|
+
* `s.charAt(i)` - THE ONE STRING METHOD, AND WHY ONLY THIS ONE.
|
|
615
|
+
*
|
|
616
|
+
* The note at the top says a folder that models String eventually deletes a
|
|
617
|
+
* real finding, and that is still the policy. This is the exception, taken
|
|
618
|
+
* because 299 of BenchmarkJava's cases decide a switch with it and because it
|
|
619
|
+
* is as exact as arithmetic: a Java String is immutable, so a string literal,
|
|
620
|
+
* or a local declared once with one, has one character at each index forever.
|
|
621
|
+
* An index out of range throws at runtime, and folds to "cannot tell".
|
|
622
|
+
*
|
|
623
|
+
* Nothing else about strings is folded - not length(), not equals(), not
|
|
624
|
+
* substring() - and a string that is a parameter, a field, or written twice
|
|
625
|
+
* is not a string this knows.
|
|
626
|
+
*/
|
|
627
|
+
const CHARACTER_LITERALS = new Set(['character_literal', 'char_literal', 'rune_literal']);
|
|
628
|
+
|
|
629
|
+
const characterCode = (raw: string): Folded => {
|
|
630
|
+
// Printable ASCII only, and no escapes: '\n' and 'é' are not worth being wrong about.
|
|
631
|
+
const match = /^'([\x20-\x26\x28-\x5b\x5d-\x7e])'$/.exec(raw.trim());
|
|
632
|
+
return match?.[1] !== undefined ? match[1].charCodeAt(0) : undefined;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const stringConstantOf = (node: Node, body: Node, depth: number): string | undefined => {
|
|
636
|
+
if (depth > 12) return undefined;
|
|
637
|
+
let n: Node = node;
|
|
638
|
+
while (n.type === 'parenthesized_expression' && n.namedChildren.length === 1) n = n.namedChildren[0] ?? n;
|
|
639
|
+
if (n.type === 'string_literal') {
|
|
640
|
+
const match = /^"([^"\\]*)"$/.exec(n.text);
|
|
641
|
+
return match?.[1];
|
|
642
|
+
}
|
|
643
|
+
if (n.type !== 'identifier') return undefined;
|
|
644
|
+
const facts = soleWrite(n.text, body);
|
|
645
|
+
if (!facts || !writeReaches(facts, n)) return undefined;
|
|
646
|
+
const cycleKey = `${body.startIndex}:${body.endIndex}#string#${n.text}`;
|
|
647
|
+
if (resolving.has(cycleKey)) return undefined;
|
|
648
|
+
resolving.add(cycleKey);
|
|
649
|
+
const value = stringConstantOf(facts.value, body, depth + 1);
|
|
650
|
+
resolving.delete(cycleKey);
|
|
651
|
+
return value;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const charAtOf = (call: Node, body: Node, depth: number): Folded => {
|
|
655
|
+
if (call.childForFieldName('name')?.text !== 'charAt') return undefined;
|
|
656
|
+
const args = (call.childForFieldName('arguments')?.namedChildren ?? []).filter((c): c is Node => c !== null);
|
|
657
|
+
const receiver = call.childForFieldName('object');
|
|
658
|
+
if (args.length !== 1 || !receiver) return undefined;
|
|
659
|
+
const s = stringConstantOf(receiver, body, depth + 1);
|
|
660
|
+
const index = foldExpression(args[0] ?? null, body, depth + 1);
|
|
661
|
+
if (s === undefined || typeof index !== 'number' || index < 0 || index >= s.length) return undefined;
|
|
662
|
+
return s.charCodeAt(index);
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
/*
|
|
666
|
+
* THE OPERATOR IS NOT ALWAYS A FIELD, AND PYTHON IS WHY.
|
|
667
|
+
*
|
|
668
|
+
* Java, JavaScript, TypeScript, Go, PHP and the C family all name the two
|
|
669
|
+
* sides of `a > b` left and right, and the `>` operator. Python names none of
|
|
670
|
+
* the three, because Python allows `1 < x < 5` - a comparison there is a LIST
|
|
671
|
+
* of operands with a LIST of operators, so there is no pair to name.
|
|
672
|
+
*
|
|
673
|
+
* The consequence was total rather than partial: no Python comparison folded
|
|
674
|
+
* anywhere, so every constant-branch decoy in Python stayed a false positive
|
|
675
|
+
* and nothing said so.
|
|
676
|
+
*
|
|
677
|
+
* The fallback reads the shape instead of the field names: exactly three
|
|
678
|
+
* children, the outer two named, the middle one an unnamed token. That token
|
|
679
|
+
* is the operator, whatever the grammar calls it. A chained comparison has
|
|
680
|
+
* five children and returns null, which means "cannot tell", which is the
|
|
681
|
+
* safe direction. `a.b` and `a instanceof B` match the shape and hand over
|
|
682
|
+
* `.` and `instanceof`, neither of which the switch below knows, so they fold
|
|
683
|
+
* to undefined exactly as they did before.
|
|
684
|
+
*/
|
|
685
|
+
const binaryParts = (node: Node): { left: Node; right: Node; operator: string } | null => {
|
|
686
|
+
const left = node.childForFieldName('left');
|
|
687
|
+
const right = node.childForFieldName('right');
|
|
688
|
+
const named = node.childForFieldName('operator')?.text ?? '';
|
|
689
|
+
if (left && right && named) return { left, right, operator: named };
|
|
690
|
+
|
|
691
|
+
const children = node.children.filter((c): c is Node => c !== null);
|
|
692
|
+
if (children.length !== 3) return null;
|
|
693
|
+
const [a, op, b] = children;
|
|
694
|
+
if (!a || !op || !b) return null;
|
|
695
|
+
if (!a.isNamed || !b.isNamed || op.isNamed) return null;
|
|
696
|
+
return { left: a, right: b, operator: op.text };
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
/*
|
|
700
|
+
* The same problem one operand down, and this one was not failing safe.
|
|
701
|
+
*
|
|
702
|
+
* Python's not_operator has no operator field either - `not` is an unnamed
|
|
703
|
+
* child, like the `>` above. The unary branch needs an operator field so it
|
|
704
|
+
* skipped, and the single-child unwrap below picked up a node with exactly
|
|
705
|
+
* one named child and returned it. `not True` folded to TRUE.
|
|
706
|
+
*
|
|
707
|
+
* Everything else in this folder fails toward "cannot tell", where the worst
|
|
708
|
+
* outcome is a false positive that was already there. This one failed toward
|
|
709
|
+
* a CONFIDENT WRONG ANSWER: the engine believed a branch ran when it did not,
|
|
710
|
+
* skipped the arm that really ran, and stopped reporting a real flow. That is
|
|
711
|
+
* the failure this tool exists to prevent, and it was sitting inside the
|
|
712
|
+
* feature that was supposed to be about precision.
|
|
713
|
+
*
|
|
714
|
+
* Restricted to the three operators the switch can actually resolve. An
|
|
715
|
+
* unknown leading token is not treated as unary at all, and falls through to
|
|
716
|
+
* the unwrap guard, which now refuses it.
|
|
717
|
+
*/
|
|
718
|
+
const UNARY_TOKENS = new Set(['!', 'not', '-']);
|
|
719
|
+
|
|
720
|
+
const unaryParts = (node: Node): { operand: Node; operator: string } | null => {
|
|
721
|
+
const operand = node.childForFieldName('argument') ?? node.childForFieldName('operand');
|
|
722
|
+
const operator = node.childForFieldName('operator')?.text ?? '';
|
|
723
|
+
if (operand && operator) return { operand, operator };
|
|
724
|
+
|
|
725
|
+
const children = node.children.filter((c): c is Node => c !== null);
|
|
726
|
+
if (children.length !== 2) return null;
|
|
727
|
+
const [op, value] = children;
|
|
728
|
+
if (!op || !value || op.isNamed || !value.isNamed) return null;
|
|
729
|
+
if (!UNARY_TOKENS.has(op.text)) return null;
|
|
730
|
+
return { operand: value, operator: op.text };
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
/*
|
|
734
|
+
* Unwrapping a single-child node is only safe when nothing was thrown away.
|
|
735
|
+
*
|
|
736
|
+
* `(x)` discards two brackets and means x. `not x` discards the word `not`
|
|
737
|
+
* and does NOT mean x. Both have one named child, and the old test could not
|
|
738
|
+
* tell them apart - which is how `not True` became true.
|
|
739
|
+
*
|
|
740
|
+
* So the test is on what gets discarded rather than on the node's name: every
|
|
741
|
+
* unnamed child must be pure punctuation. A discarded WORD, or any symbol not
|
|
742
|
+
* on this list, means the node transforms its operand in a way this folder
|
|
743
|
+
* has not been taught, and the answer is "cannot tell".
|
|
744
|
+
*
|
|
745
|
+
* `$` is here for PHP, whose variable_name wraps a bare name in a `$` token.
|
|
746
|
+
*/
|
|
747
|
+
const WRAPPER_PUNCTUATION = new Set(['(', ')', '[', ']', '{', '}', ',', ';', '$']);
|
|
748
|
+
|
|
749
|
+
/*
|
|
750
|
+
* CHECK THE OPERATOR BEFORE FOLDING THE OPERANDS, AND THIS IS NOT A TIDY-UP.
|
|
751
|
+
*
|
|
752
|
+
* Reading the operator off the shape instead of a field made binaryParts
|
|
753
|
+
* match things it was never meant to: `a.b` is three children with the outer
|
|
754
|
+
* two named, so is `a instanceof B`, so is every member access in every
|
|
755
|
+
* minified file on earth. The switch below has no case for `.`, so all of
|
|
756
|
+
* them still folded to undefined and the ANSWER was never wrong.
|
|
757
|
+
*
|
|
758
|
+
* The cost was. Folding an operand that turns out to be a bare name calls
|
|
759
|
+
* constantOfName, and constantOfName walks the entire enclosing function
|
|
760
|
+
* looking for assignments. DVWA ships an inlined sha256 - one 19KB function,
|
|
761
|
+
* thousands of member accesses - and the scan went from finishing to running
|
|
762
|
+
* out of memory on 1.1MB of source with 7GB free.
|
|
763
|
+
*
|
|
764
|
+
* It did not show up in the suite, the benchmark, or the metamorphic run.
|
|
765
|
+
* Fixture files are small and tidy, and BenchmarkJava's generated cases are
|
|
766
|
+
* about forty lines each; this needed one real file written by somebody
|
|
767
|
+
* optimising for size. The corpus check is in the repo precisely so that
|
|
768
|
+
* "all tests pass" is not the last word, and this is the first time it has
|
|
769
|
+
* caught something the tests could not.
|
|
770
|
+
*
|
|
771
|
+
* So the operator is checked first. An operator with no case cannot change
|
|
772
|
+
* any answer, and now it does not cost a tree walk to find that out.
|
|
773
|
+
*/
|
|
774
|
+
const FOLDABLE_OPERATORS = new Set([
|
|
775
|
+
'+', '-', '*', '/', '%',
|
|
776
|
+
'>', '<', '>=', '<=', '==', '!=',
|
|
777
|
+
'&&', 'and', '||', 'or',
|
|
778
|
+
]);
|
|
779
|
+
|
|
780
|
+
const INTEGER_DIVISION = language === 'java' || language === 'c' || language === 'cpp' || language === 'go';
|
|
781
|
+
/*
|
|
782
|
+
* Where arithmetic stops being exact. Java, C, C++ and Go wrap at their type's
|
|
783
|
+
* width - checked here at 32 bits, the narrowest `int` any of them uses - and
|
|
784
|
+
* JavaScript, Python and PHP lose integer precision past 2^53. Outside that,
|
|
785
|
+
* "cannot tell".
|
|
786
|
+
*/
|
|
787
|
+
const inRange = (value: number): Folded =>
|
|
788
|
+
INTEGER_DIVISION
|
|
789
|
+
? value >= -2147483648 && value <= 2147483647 ? value : undefined
|
|
790
|
+
: Math.abs(value) <= 2 ** 53 ? value : undefined;
|
|
791
|
+
|
|
792
|
+
const foldExpression = (node: Node | null, body: Node, depth: number): Folded => {
|
|
793
|
+
if (!node || depth > 12) return undefined;
|
|
794
|
+
|
|
795
|
+
// 'B' is 66 in Java, C, C++ and Go, where a character IS an integer. Only
|
|
796
|
+
// the grammars that have a character literal get this; a quoted 'B' in
|
|
797
|
+
// JavaScript, Python or PHP is a string, and strings are not folded.
|
|
798
|
+
if (CHARACTER_LITERALS.has(node.type)) return characterCode(node.text);
|
|
799
|
+
if (language === 'java' && node.type === 'method_invocation') return charAtOf(node, body, depth);
|
|
800
|
+
|
|
801
|
+
const binary = binaryParts(node);
|
|
802
|
+
|
|
803
|
+
if (binary) {
|
|
804
|
+
const { left, right, operator } = binary;
|
|
805
|
+
if (!FOLDABLE_OPERATORS.has(operator)) return undefined;
|
|
806
|
+
const a = foldExpression(left, body, depth + 1);
|
|
807
|
+
const b = foldExpression(right, body, depth + 1);
|
|
808
|
+
if (a === undefined || b === undefined) return undefined;
|
|
809
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
810
|
+
switch (operator) {
|
|
811
|
+
case '+': return inRange(a + b);
|
|
812
|
+
case '-': return inRange(a - b);
|
|
813
|
+
case '*': return inRange(a * b);
|
|
814
|
+
// Integer division truncates in Java, C, C++ and Go. In JavaScript,
|
|
815
|
+
// Python and PHP `7 / 2` is 3.5, and in Python `-7 % 3` is 2.
|
|
816
|
+
case '/': return b === 0 || !INTEGER_DIVISION ? undefined : inRange(Math.trunc(a / b));
|
|
817
|
+
case '%': return b === 0 || (language === 'python' && (a < 0 || b < 0)) ? undefined : inRange(a % b);
|
|
818
|
+
case '>': return a > b;
|
|
819
|
+
case '<': return a < b;
|
|
820
|
+
case '>=': return a >= b;
|
|
821
|
+
case '<=': return a <= b;
|
|
822
|
+
case '==': return a === b;
|
|
823
|
+
case '!=': return a !== b;
|
|
824
|
+
default: return undefined;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
828
|
+
switch (operator) {
|
|
829
|
+
case '&&': case 'and': return a && b;
|
|
830
|
+
case '||': case 'or': return a || b;
|
|
831
|
+
case '==': return a === b;
|
|
832
|
+
case '!=': return a !== b;
|
|
833
|
+
default: return undefined;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// `!x`, Python's `not x`, and unary minus.
|
|
840
|
+
const unary = unaryParts(node);
|
|
841
|
+
if (unary) {
|
|
842
|
+
const value = foldExpression(unary.operand, body, depth + 1);
|
|
843
|
+
if ((unary.operator === '!' || unary.operator === 'not') && typeof value === 'boolean') {
|
|
844
|
+
return !value;
|
|
845
|
+
}
|
|
846
|
+
if (unary.operator === '-' && typeof value === 'number') return inRange(-value);
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Parentheses and other single-child wrappers - see WRAPPER_PUNCTUATION.
|
|
851
|
+
const named = node.namedChildren.filter((c): c is Node => c !== null);
|
|
852
|
+
if (named.length === 1) {
|
|
853
|
+
const discardsOnlyPunctuation = node.children.every(
|
|
854
|
+
(c) => c === null || c.isNamed || WRAPPER_PUNCTUATION.has(c.text),
|
|
855
|
+
);
|
|
856
|
+
if (discardsOnlyPunctuation) return foldExpression(named[0] ?? null, body, depth + 1);
|
|
857
|
+
return undefined;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (named.length === 0) {
|
|
861
|
+
const literal = foldLiteral(text(node));
|
|
862
|
+
if (literal !== undefined) return literal;
|
|
863
|
+
/*
|
|
864
|
+
* A bare word that is not a literal is a name, and asking whether it is
|
|
865
|
+
* bound to a constant is the whole point of constantOfName.
|
|
866
|
+
*
|
|
867
|
+
* This used to test `/identifier/` against the NODE TYPE, which is true
|
|
868
|
+
* of Java's `identifier` and JavaScript's `identifier` and false of PHP -
|
|
869
|
+
* a PHP variable is a `variable_name` wrapping a `name`, and neither
|
|
870
|
+
* spelling contains the word. So no PHP variable folded, and every PHP
|
|
871
|
+
* condition mentioning one was undecidable.
|
|
872
|
+
*
|
|
873
|
+
* Testing the TEXT covers all eight without a list of node names to keep
|
|
874
|
+
* up to date. A keyword like `null` or `nil` reaches constantOfName,
|
|
875
|
+
* finds no assignment, and returns undefined - the same answer it gave
|
|
876
|
+
* before, by a shorter route.
|
|
877
|
+
*/
|
|
878
|
+
if (/^[A-Za-z_][\w]*$/.test(text(node).trim())) {
|
|
879
|
+
return constantOfName(text(node), body, depth, node);
|
|
880
|
+
}
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return undefined;
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
/*
|
|
888
|
+
* THE THREE PARTS OF A TERNARY, WHICH NOT EVERY GRAMMAR AGREES TO NAME.
|
|
889
|
+
*
|
|
890
|
+
* The first version of the folder read `condition`, `consequence` and
|
|
891
|
+
* `alternative` as fields, which is right for Java, JavaScript, TypeScript,
|
|
892
|
+
* Go and the C family - and quietly wrong for two of the eight:
|
|
893
|
+
*
|
|
894
|
+
* python no fields at all, children in WRITTEN order: A if C else B,
|
|
895
|
+
* so the middle child is the condition and the first is the value
|
|
896
|
+
* php names `condition` and `alternative`, but not `consequence`
|
|
897
|
+
*
|
|
898
|
+
* Measured, not assumed. The folder was written against Java, verified
|
|
899
|
+
* against Java, and would have shipped carrying a language-shaped hole -
|
|
900
|
+
* Python would have kept tracing into dead branches while every other
|
|
901
|
+
* language stopped, and nothing would have failed to say so.
|
|
902
|
+
*
|
|
903
|
+
* Python is matched on the language id rather than on a shape heuristic like
|
|
904
|
+
* "no condition field and three children". A heuristic would be one grammar
|
|
905
|
+
* away from silently reading a condition as a value, and that error deletes
|
|
906
|
+
* findings rather than adding them.
|
|
907
|
+
*/
|
|
908
|
+
const ternaryParts = (
|
|
909
|
+
node: Node,
|
|
910
|
+
): { condition: Node | null; consequence: Node | null; alternative: Node | null } => {
|
|
911
|
+
const named = node.namedChildren.filter((c): c is Node => c !== null);
|
|
912
|
+
|
|
913
|
+
if (language === 'python' && named.length === 3) {
|
|
914
|
+
return {
|
|
915
|
+
consequence: named[0] ?? null,
|
|
916
|
+
condition: named[1] ?? null,
|
|
917
|
+
alternative: named[2] ?? null,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const condition = node.childForFieldName('condition');
|
|
922
|
+
const alternative = node.childForFieldName('alternative');
|
|
923
|
+
let consequence = node.childForFieldName('consequence');
|
|
924
|
+
|
|
925
|
+
// PHP leaves the middle one unnamed; it is whichever child is left over.
|
|
926
|
+
if (!consequence && condition) {
|
|
927
|
+
const same = (a: Node, b: Node | null): boolean =>
|
|
928
|
+
b !== null && a.startIndex === b.startIndex && a.endIndex === b.endIndex;
|
|
929
|
+
consequence = named.find((c) => !same(c, condition) && !same(c, alternative)) ?? null;
|
|
930
|
+
/*
|
|
931
|
+
* `$a ?: $b` has two operands, not three, so there IS no left-over child
|
|
932
|
+
* and the search above finds nothing. In that form the value when the
|
|
933
|
+
* condition holds is the condition itself.
|
|
934
|
+
*
|
|
935
|
+
* Falling back to the condition rather than to null matters because null
|
|
936
|
+
* would make a decidable elvis select NOTHING, and selecting nothing is
|
|
937
|
+
* how a folder deletes a flow instead of merely failing to narrow one.
|
|
938
|
+
*/
|
|
939
|
+
consequence ??= condition;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return { condition, consequence, alternative };
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
/** True or false when the condition is decidable, undefined when it is not. */
|
|
946
|
+
const conditionTruth = (node: Node | null, body: Node): boolean | undefined => {
|
|
947
|
+
const value = foldExpression(node, body, 0);
|
|
948
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* The branch of an if-statement that certainly does not run, if any.
|
|
953
|
+
*
|
|
954
|
+
* `consequence ?? body` is PHP, and it is the reason this was HALF broken
|
|
955
|
+
* rather than broken - which is worse, because half a feature looks like a
|
|
956
|
+
* working one. PHP names the else `alternative` like everyone else, so a
|
|
957
|
+
* condition that folded TRUE correctly dropped the dead else. It calls the
|
|
958
|
+
* then-block `body`, not `consequence`, so a condition that folded FALSE
|
|
959
|
+
* asked for a field that does not exist, got null, and kept the dead arm.
|
|
960
|
+
*
|
|
961
|
+
* Same statement, same file, same folder: one direction fixed, one direction
|
|
962
|
+
* silently untouched. Nothing failed, because "no dead branch here" is a
|
|
963
|
+
* perfectly ordinary answer.
|
|
964
|
+
*/
|
|
965
|
+
const deadBranchOf = (node: Node, body: Node): Node | null => {
|
|
966
|
+
const truth = conditionTruth(node.childForFieldName('condition'), body);
|
|
967
|
+
if (truth === undefined) return null;
|
|
968
|
+
return truth
|
|
969
|
+
? node.childForFieldName('alternative')
|
|
970
|
+
: (node.childForFieldName('consequence') ?? node.childForFieldName('body'));
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
/*
|
|
974
|
+
* WHICH CASES OF A SWITCH CAN RUN.
|
|
975
|
+
*
|
|
976
|
+
* Decided only when the selector AND every case label fold to a number or a
|
|
977
|
+
* boolean of the same kind. One label the folder cannot read - an enum
|
|
978
|
+
* constant, a string, a pattern - and the whole switch is undecided, because
|
|
979
|
+
* that label might be the one that matches.
|
|
980
|
+
*
|
|
981
|
+
* From the matching case (or `default` when none matches, or nothing at all
|
|
982
|
+
* when there is no default), execution runs on into the next case until a
|
|
983
|
+
* `break`, `return`, `throw` or `continue` sits DIRECTLY in a case - one
|
|
984
|
+
* nested inside an `if` might not run, so it does not stop the fall. Go and
|
|
985
|
+
* Java's arrow cases do not fall through; Go's explicit `fallthrough` does.
|
|
986
|
+
* Every case outside that run is dead.
|
|
987
|
+
*
|
|
988
|
+
* C has one more way in: `goto`. A label inside the switch can be jumped to
|
|
989
|
+
* whatever the selector says, and so can a case label buried inside a nested
|
|
990
|
+
* block (Duff's device). Either one, and the switch is left undecided.
|
|
991
|
+
*
|
|
992
|
+
* `certain` answers a narrower question for the tracer: does this statement
|
|
993
|
+
* run EVERY time the switch does? Only if it is in the run and no jump of any
|
|
994
|
+
* kind - nested or not - comes before it in the run. A clean write that is
|
|
995
|
+
* certain may clear a value; one that is merely possible may not.
|
|
996
|
+
*/
|
|
997
|
+
const switchDecision = (node: Node, body: Node): SwitchDecision | null => {
|
|
998
|
+
const shape = switchShape(node);
|
|
999
|
+
if (!shape || !shape.selector) return null;
|
|
1000
|
+
const selector = foldExpression(shape.selector, body, 0);
|
|
1001
|
+
if (selector === undefined) return null;
|
|
1002
|
+
|
|
1003
|
+
let matched = -1;
|
|
1004
|
+
let defaultIndex = -1;
|
|
1005
|
+
for (let i = 0; i < shape.groups.length; i++) {
|
|
1006
|
+
const group = shape.groups[i];
|
|
1007
|
+
if (!group) return null;
|
|
1008
|
+
if (group.labels === 'default') {
|
|
1009
|
+
if (defaultIndex === -1) defaultIndex = i;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
for (const label of group.labels) {
|
|
1013
|
+
const value = foldExpression(label, body, 0);
|
|
1014
|
+
if (value === undefined || typeof value !== typeof selector) return null;
|
|
1015
|
+
if (matched === -1 && value === selector) matched = i;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const start = matched !== -1 ? matched : defaultIndex;
|
|
1019
|
+
|
|
1020
|
+
const live: number[] = [];
|
|
1021
|
+
if (start !== -1) {
|
|
1022
|
+
for (let i = start; i < shape.groups.length; i++) {
|
|
1023
|
+
const group = shape.groups[i];
|
|
1024
|
+
if (!group) break;
|
|
1025
|
+
live.push(i);
|
|
1026
|
+
if (shape.fallthrough === 'never') break;
|
|
1027
|
+
if (shape.fallthrough === 'explicit') {
|
|
1028
|
+
if (group.statements[group.statements.length - 1]?.type !== 'fallthrough_statement') break;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (group.statements.some((s) => TERMINATORS.has(s.type))) break;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const liveSet = new Set(live);
|
|
1035
|
+
const dead = shape.groups.filter((_, i) => !liveSet.has(i)).map((g) => g.node);
|
|
1036
|
+
const inside = (outer: Node, inner: Node): boolean =>
|
|
1037
|
+
inner.startIndex >= outer.startIndex && inner.endIndex <= outer.endIndex;
|
|
1038
|
+
|
|
1039
|
+
const certain = (n: Node): boolean => {
|
|
1040
|
+
for (const i of live) {
|
|
1041
|
+
const group = shape.groups[i];
|
|
1042
|
+
if (!group) return false;
|
|
1043
|
+
const jumpBefore = containsJumpBefore(group.node, n.startIndex);
|
|
1044
|
+
if (inside(group.node, n)) return !jumpBefore;
|
|
1045
|
+
if (jumpBefore) return false;
|
|
1046
|
+
}
|
|
1047
|
+
return false;
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
const matchedGroup = matched !== -1 ? shape.groups[matched] : undefined;
|
|
1051
|
+
const line = node.startPosition.row + 1;
|
|
1052
|
+
const note =
|
|
1053
|
+
matchedGroup && matchedGroup.labels !== 'default'
|
|
1054
|
+
? `the switch on line ${line} always takes \`case ${text(matchedGroup.labels.find((l) => foldExpression(l, body, 0) === selector) ?? null)}\``
|
|
1055
|
+
: start !== -1
|
|
1056
|
+
? `the switch on line ${line} matches no case, so only \`default\` runs`
|
|
1057
|
+
: `the switch on line ${line} matches no case and has no default, so none of it runs`;
|
|
1058
|
+
|
|
1059
|
+
return { dead, certain, note };
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
return { foldExpression, conditionTruth, ternaryParts, deadBranchOf, switchDecision, writtenOtherwise, plainWriteCount };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** What a decided switch tells the tracer and the constant proof. */
|
|
1066
|
+
export interface SwitchDecision {
|
|
1067
|
+
/** Case groups that cannot run. */
|
|
1068
|
+
readonly dead: readonly Node[];
|
|
1069
|
+
/** Whether a node inside the switch runs every time the switch does. */
|
|
1070
|
+
readonly certain: (node: Node) => boolean;
|
|
1071
|
+
/** A sentence for a receipt. */
|
|
1072
|
+
readonly note: string;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Every switch node type, in every grammar here - decided or not. */
|
|
1076
|
+
export const SWITCH_NODES = new Set([
|
|
1077
|
+
'switch_statement', // JS, TS, C, C++, PHP
|
|
1078
|
+
'switch_expression', // Java - the statement form too
|
|
1079
|
+
'expression_switch_statement', // Go
|
|
1080
|
+
]);
|
|
1081
|
+
|
|
1082
|
+
/** A statement that leaves a switch when it sits directly in a case. */
|
|
1083
|
+
const TERMINATORS = new Set([
|
|
1084
|
+
'break_statement',
|
|
1085
|
+
'continue_statement',
|
|
1086
|
+
'return_statement',
|
|
1087
|
+
'throw_statement',
|
|
1088
|
+
'yield_statement',
|
|
1089
|
+
'goto_statement',
|
|
1090
|
+
]);
|
|
1091
|
+
|
|
1092
|
+
/** Any statement that can leave early, wherever it is nested. */
|
|
1093
|
+
const JUMPS = new Set([...TERMINATORS, 'labeled_statement']);
|
|
1094
|
+
|
|
1095
|
+
function containsJumpBefore(node: Node, before: number): boolean {
|
|
1096
|
+
if (node.startIndex >= before) return false;
|
|
1097
|
+
if (JUMPS.has(node.type)) return true;
|
|
1098
|
+
for (const child of node.namedChildren) {
|
|
1099
|
+
if (child && child.startIndex < before && containsJumpBefore(child, before)) return true;
|
|
1100
|
+
}
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
interface SwitchGroup {
|
|
1105
|
+
readonly node: Node;
|
|
1106
|
+
readonly labels: readonly Node[] | 'default';
|
|
1107
|
+
readonly statements: readonly Node[];
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
interface SwitchShape {
|
|
1111
|
+
readonly selector: Node | null;
|
|
1112
|
+
readonly groups: readonly SwitchGroup[];
|
|
1113
|
+
readonly fallthrough: 'c-style' | 'never' | 'explicit';
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const named = (n: Node | null | undefined): Node[] =>
|
|
1117
|
+
(n?.namedChildren ?? []).filter((c): c is Node => c !== null);
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Read a switch into one shape, whatever the grammar calls its parts. Five
|
|
1121
|
+
* grammars, five vocabularies - measured from each grammar's own trees, not
|
|
1122
|
+
* assumed from one. Null means "not a shape this knows", and an unknown shape
|
|
1123
|
+
* is an undecided switch.
|
|
1124
|
+
*/
|
|
1125
|
+
function switchShape(node: Node): SwitchShape | null {
|
|
1126
|
+
const defaultOf = (n: Node): boolean => /^default\b/.test(n.text.trim());
|
|
1127
|
+
|
|
1128
|
+
// Java: switch_block of statement groups (falls through) or arrow rules (never).
|
|
1129
|
+
if (node.type === 'switch_expression') {
|
|
1130
|
+
const block = node.childForFieldName('body');
|
|
1131
|
+
const kids = named(block);
|
|
1132
|
+
const isGroup = (k: Node): boolean => k.type === 'switch_block_statement_group';
|
|
1133
|
+
const isRule = (k: Node): boolean => k.type === 'switch_rule';
|
|
1134
|
+
if (kids.length === 0 || !(kids.every(isGroup) || kids.every(isRule))) return null;
|
|
1135
|
+
const groups: SwitchGroup[] = [];
|
|
1136
|
+
for (const k of kids) {
|
|
1137
|
+
const labels = named(k).filter((c) => c.type === 'switch_label');
|
|
1138
|
+
const statements = named(k).filter((c) => c.type !== 'switch_label');
|
|
1139
|
+
if (labels.length === 0) return null;
|
|
1140
|
+
if (labels.some(defaultOf)) {
|
|
1141
|
+
if (labels.length > 1) return null; // `case 1: default:` - keep it simple
|
|
1142
|
+
groups.push({ node: k, labels: 'default', statements });
|
|
1143
|
+
} else {
|
|
1144
|
+
groups.push({ node: k, labels: labels.flatMap((l) => named(l)), statements });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
selector: node.childForFieldName('condition'),
|
|
1149
|
+
groups,
|
|
1150
|
+
fallthrough: kids.every(isRule) ? 'never' : 'c-style',
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Go: cases directly under the switch; `fallthrough` must be asked for.
|
|
1155
|
+
if (node.type === 'expression_switch_statement') {
|
|
1156
|
+
const groups: SwitchGroup[] = [];
|
|
1157
|
+
for (const k of named(node)) {
|
|
1158
|
+
if (k.type === 'expression_case') {
|
|
1159
|
+
const value = k.childForFieldName('value');
|
|
1160
|
+
const labels = value?.type === 'expression_list' ? named(value) : value ? [value] : [];
|
|
1161
|
+
if (labels.length === 0) return null;
|
|
1162
|
+
groups.push({ node: k, labels, statements: named(k).filter((c) => !value || c.startIndex !== value.startIndex) });
|
|
1163
|
+
} else if (k.type === 'default_case') {
|
|
1164
|
+
groups.push({ node: k, labels: 'default', statements: named(k) });
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return { selector: node.childForFieldName('value'), groups, fallthrough: 'explicit' };
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (node.type !== 'switch_statement') return null;
|
|
1171
|
+
const block = node.childForFieldName('body');
|
|
1172
|
+
const selector = node.childForFieldName('condition') ?? node.childForFieldName('value');
|
|
1173
|
+
const kids = named(block);
|
|
1174
|
+
const groups: SwitchGroup[] = [];
|
|
1175
|
+
|
|
1176
|
+
// A label inside the switch, or a case label that is not directly in its
|
|
1177
|
+
// body, is a way in that the selector does not control. Decline.
|
|
1178
|
+
const hasHiddenEntry = (n: Node, depthInBody: number, inNestedSwitch: boolean): boolean => {
|
|
1179
|
+
for (const c of named(n)) {
|
|
1180
|
+
if (c.type === 'labeled_statement') return true;
|
|
1181
|
+
if (!inNestedSwitch && depthInBody > 0 && (c.type === 'case_statement' || c.type === 'switch_case')) return true;
|
|
1182
|
+
// A nested switch's cases are its own; a label inside it is still a way in.
|
|
1183
|
+
if (hasHiddenEntry(c, depthInBody + 1, inNestedSwitch || SWITCH_NODES.has(c.type))) return true;
|
|
1184
|
+
}
|
|
1185
|
+
return false;
|
|
1186
|
+
};
|
|
1187
|
+
if (!block || hasHiddenEntry(block, 0, false)) return null;
|
|
1188
|
+
|
|
1189
|
+
for (const k of kids) {
|
|
1190
|
+
if (k.type === 'switch_case' || k.type === 'case_statement' || k.type === 'switch_default' || k.type === 'default_statement') {
|
|
1191
|
+
const value = k.childForFieldName('value');
|
|
1192
|
+
// GNU's `case 1 ... 5:` is a RANGE, and the grammar reads it as `case 1`
|
|
1193
|
+
// followed by an error. A label that did not parse cleanly is not read.
|
|
1194
|
+
if (value && (value.hasError || /\.\.\./.test(k.text.split(':')[0] ?? ''))) return null;
|
|
1195
|
+
if (k.hasError) return null;
|
|
1196
|
+
const isDefault = k.type === 'switch_default' || k.type === 'default_statement' || (!value && defaultOf(k));
|
|
1197
|
+
const statements = named(k).filter((c) => !value || c.startIndex !== value.startIndex);
|
|
1198
|
+
if (isDefault) groups.push({ node: k, labels: 'default', statements });
|
|
1199
|
+
else if (value) groups.push({ node: k, labels: [value], statements });
|
|
1200
|
+
else return null;
|
|
1201
|
+
} else if (k.type !== 'comment') {
|
|
1202
|
+
return null; // a statement before the first case, or a shape we do not know
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return { selector, groups, fallthrough: 'c-style' };
|
|
1206
|
+
}
|