Skip to content

Commit de52f9b

Browse files
committed
Swift: generated extractor tests
1 parent fceea04 commit de52f9b

File tree

299 files changed

+1969
-288
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

299 files changed

+1969
-288
lines changed

docs/codeql/ql-training/query-examples/java/query-injection-java-1.ql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ from Method m, MethodAccess ma
44
where
55
m.getName().matches("sparql%Query") and
66
ma.getMethod() = m
7-
select ma, m
7+
select ma, m

swift/codegen/codegen.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def _parse_args() -> argparse.Namespace:
3131
p.add_argument("--ql-stub-output", type=_abspath, default=paths.swift_dir / "ql/lib/codeql/swift/elements",
3232
help="output directory for QL stub/customization files (default %(default)s). Defines also the "
3333
"generated qll file importing every class file")
34+
p.add_argument("--ql-test-output", type=_abspath, default=paths.swift_dir / "ql/test/extractor-tests/generated",
35+
help="output directory for QL generated extractor test files (default %(default)s)")
3436
p.add_argument("--ql-format", action="store_true", default=True,
3537
help="use codeql to autoformat QL files (which is the default)")
3638
p.add_argument("--no-ql-format", action="store_false", dest="ql_format", help="do not format QL files")

swift/codegen/generators/qlgen.py

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import pathlib
55
import subprocess
6+
import typing
67

78
import inflection
89

@@ -11,41 +12,46 @@
1112
log = logging.getLogger(__name__)
1213

1314

15+
class FormatError(Exception):
16+
pass
17+
18+
1419
def get_ql_property(cls: schema.Class, prop: schema.Property):
20+
common_args = dict(
21+
type=prop.type if not prop.is_predicate else "predicate",
22+
skip_qltest="no_qltest" in prop.tags,
23+
is_child=prop.is_child,
24+
is_optional=prop.is_optional,
25+
is_predicate=prop.is_predicate,
26+
)
1527
if prop.is_single:
1628
return ql.Property(
29+
**common_args,
1730
singular=inflection.camelize(prop.name),
18-
type=prop.type,
1931
tablename=inflection.tableize(cls.name),
2032
tableparams=["this"] + ["result" if p is prop else "_" for p in cls.properties if p.is_single],
21-
is_child=prop.is_child,
2233
)
2334
elif prop.is_repeated:
2435
return ql.Property(
36+
**common_args,
2537
singular=inflection.singularize(inflection.camelize(prop.name)),
2638
plural=inflection.pluralize(inflection.camelize(prop.name)),
27-
type=prop.type,
2839
tablename=inflection.tableize(f"{cls.name}_{prop.name}"),
2940
tableparams=["this", "index", "result"],
30-
is_optional=prop.is_optional,
31-
is_child=prop.is_child,
3241
)
3342
elif prop.is_optional:
3443
return ql.Property(
44+
**common_args,
3545
singular=inflection.camelize(prop.name),
36-
type=prop.type,
3746
tablename=inflection.tableize(f"{cls.name}_{prop.name}"),
3847
tableparams=["this", "result"],
39-
is_optional=True,
40-
is_child=prop.is_child,
4148
)
4249
elif prop.is_predicate:
4350
return ql.Property(
51+
**common_args,
4452
singular=inflection.camelize(prop.name, uppercase_first_letter=False),
45-
type="predicate",
4653
tablename=inflection.underscore(f"{cls.name}_{prop.name}"),
4754
tableparams=["this"],
48-
is_predicate=True,
4955
)
5056

5157

@@ -77,38 +83,75 @@ def get_classes_used_by(cls: ql.Class):
7783

7884
def is_generated(file):
7985
with open(file) as contents:
80-
return next(contents).startswith("// generated")
86+
for line in contents:
87+
return line.startswith("// generated")
88+
return False
8189

8290

8391
def format(codeql, files):
8492
format_cmd = [codeql, "query", "format", "--in-place", "--"]
85-
format_cmd.extend(str(f) for f in files)
86-
res = subprocess.run(format_cmd, check=True, stderr=subprocess.PIPE, text=True)
93+
format_cmd.extend(str(f) for f in files if f.suffix in (".qll", ".ql"))
94+
res = subprocess.run(format_cmd, stderr=subprocess.PIPE, text=True)
95+
if res.returncode:
96+
for line in res.stderr.splitlines():
97+
log.error(line.strip())
98+
raise FormatError("QL format failed")
8799
for line in res.stderr.splitlines():
88100
log.debug(line.strip())
89101

90102

103+
def _get_all_properties(cls: ql.Class, lookup: typing.Dict[str, ql.Class]) -> typing.Iterable[ql.Property]:
104+
for b in cls.bases:
105+
for p in _get_all_properties(lookup[b], lookup):
106+
yield p
107+
for p in cls.properties:
108+
yield p
109+
110+
111+
def _get_all_properties_to_be_tested(cls: ql.Class, lookup: typing.Dict[str, ql.Class]) -> typing.Iterable[
112+
ql.PropertyForTest]:
113+
# deduplicate using id
114+
already_seen = set()
115+
for p in _get_all_properties(cls, lookup):
116+
if not p.skip_qltest and id(p) not in already_seen:
117+
already_seen.add(id(p))
118+
yield ql.PropertyForTest(p.getter, p.type, p.is_single, p.is_predicate, p.is_repeated)
119+
120+
121+
def _partition(l, pred):
122+
""" partitions a list according to boolean predicate """
123+
res = ([], [])
124+
for x in l:
125+
res[not pred(x)].append(x)
126+
return res
127+
128+
91129
def generate(opts, renderer):
92130
input = opts.schema
93131
out = opts.ql_output
94132
stub_out = opts.ql_stub_output
133+
test_out = opts.ql_test_output
134+
missing_test_source_filename = "MISSING_SOURCE.txt"
95135
existing = {q for q in out.rglob("*.qll")}
96136
existing |= {q for q in stub_out.rglob("*.qll") if is_generated(q)}
137+
existing |= {q for q in test_out.rglob("*.ql")}
138+
existing |= {q for q in test_out.rglob(missing_test_source_filename)}
97139

98140
data = schema.load(input)
99141

100142
classes = [get_ql_class(cls) for cls in data.classes]
101-
classes.sort(key=lambda cls: cls.name)
143+
lookup = {cls.name: cls for cls in classes}
144+
classes.sort(key=lambda cls: (cls.dir, cls.name))
102145
imports = {}
103146

104147
for c in classes:
105148
imports[c.name] = get_import(stub_out / c.path, opts.swift_dir)
106149

107150
for c in classes:
108-
qll = (out / c.path).with_suffix(".qll")
151+
qll = out / c.path.with_suffix(".qll")
109152
c.imports = [imports[t] for t in get_classes_used_by(c)]
110153
renderer.render(c, qll)
111-
stub_file = (stub_out / c.path).with_suffix(".qll")
154+
stub_file = stub_out / c.path.with_suffix(".qll")
112155
if not stub_file.is_file() or is_generated(stub_file):
113156
stub = ql.Stub(name=c.name, base_import=get_import(qll, opts.swift_dir))
114157
renderer.render(stub, stub_file)
@@ -120,6 +163,21 @@ def generate(opts, renderer):
120163

121164
renderer.render(ql.GetParentImplementation(classes), out / 'GetImmediateParent.qll')
122165

166+
for c in classes:
167+
if not c.final:
168+
continue
169+
test_dir = test_out / c.path
170+
test_dir.mkdir(parents=True, exist_ok=True)
171+
if not any(test_dir.glob("*.swift")):
172+
log.warning(f"no test source in {c.path}")
173+
renderer.render(ql.MissingTestInstructions(), test_dir / missing_test_source_filename)
174+
continue
175+
total_props, partial_props = _partition(_get_all_properties_to_be_tested(c, lookup),
176+
lambda p: p.is_single or p.is_predicate)
177+
renderer.render(ql.ClassTester(class_name=c.name, properties=total_props), test_dir / f"{c.name}.ql")
178+
for p in partial_props:
179+
renderer.render(ql.PropertyTester(class_name=c.name, property=p), test_dir / f"{c.name}_{p.getter}.ql")
180+
123181
renderer.cleanup(existing)
124182
if opts.ql_format:
125183
format(opts.codeql_binary, renderer.written)

swift/codegen/lib/ql.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Property:
3737
is_optional: bool = False
3838
is_predicate: bool = False
3939
is_child: bool = False
40+
skip_qltest: bool = False
4041

4142
def __post_init__(self):
4243
if self.tableparams:
@@ -63,6 +64,10 @@ def type_is_class(self):
6364
def is_repeated(self):
6465
return bool(self.plural)
6566

67+
@property
68+
def is_single(self):
69+
return not (self.is_optional or self.is_repeated or self.is_predicate)
70+
6671

6772
@dataclass
6873
class Class:
@@ -113,3 +118,33 @@ class GetParentImplementation:
113118
template: ClassVar = 'ql_parent'
114119

115120
classes: List[Class] = field(default_factory=list)
121+
122+
123+
@dataclass
124+
class PropertyForTest:
125+
getter: str
126+
type: str = None
127+
is_single: bool = False
128+
is_predicate: bool = False
129+
is_repeated: bool = False
130+
131+
132+
@dataclass
133+
class ClassTester:
134+
template: ClassVar = 'ql_test_class'
135+
136+
class_name: str
137+
properties: List[PropertyForTest] = field(default_factory=list)
138+
139+
140+
@dataclass
141+
class PropertyTester:
142+
template: ClassVar = 'ql_test_property'
143+
144+
class_name: str
145+
property: PropertyForTest
146+
147+
148+
@dataclass
149+
class MissingTestInstructions:
150+
template: ClassVar = 'ql_test_missing'

swift/codegen/lib/schema.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33
import pathlib
44
import re
55
from dataclasses import dataclass, field
6-
from typing import List, Set, Dict, ClassVar
6+
from typing import List, Set, Union, Dict, ClassVar
77

88
import yaml
99

10+
11+
class Error(Exception):
12+
13+
def __str__(self):
14+
return f"schema.Error{args}"
15+
16+
1017
root_class_name = "Element"
1118

1219

@@ -20,6 +27,7 @@ class Property:
2027
name: str
2128
type: str = None
2229
is_child: bool = False
30+
tags: List[str] = field(default_factory=list)
2331

2432

2533
@dataclass
@@ -55,6 +63,7 @@ class Class:
5563
derived: Set[str] = field(default_factory=set)
5664
properties: List[Property] = field(default_factory=list)
5765
dir: pathlib.Path = pathlib.Path()
66+
tags: List[str] = field(default_factory=list)
5867

5968

6069
@dataclass
@@ -63,18 +72,24 @@ class Schema:
6372
includes: Set[str] = field(default_factory=set)
6473

6574

66-
def _parse_property(name: str, type: str, is_child: bool = False):
67-
assert not (is_child and type[0].islower()), f"children must have class type, got {type} for {name}"
75+
def _parse_property(name: str, type: Union[str, Dict[str, str]], is_child: bool = False):
76+
if isinstance(type, dict):
77+
tags = type.get("_tags", [])
78+
type = type["type"]
79+
else:
80+
tags = []
81+
if is_child and type[0].islower():
82+
raise Error(f"children must have class type, got {type} for {name}")
6883
if type.endswith("?*"):
69-
return RepeatedOptionalProperty(name, type[:-2], is_child=is_child)
84+
return RepeatedOptionalProperty(name, type[:-2], is_child=is_child, tags=tags)
7085
elif type.endswith("*"):
71-
return RepeatedProperty(name, type[:-1], is_child=is_child)
86+
return RepeatedProperty(name, type[:-1], is_child=is_child, tags=tags)
7287
elif type.endswith("?"):
73-
return OptionalProperty(name, type[:-1], is_child=is_child)
88+
return OptionalProperty(name, type[:-1], is_child=is_child, tags=tags)
7489
elif type == "predicate":
75-
return PredicateProperty(name)
90+
return PredicateProperty(name, tags=tags)
7691
else:
77-
return SingleProperty(name, type, is_child=is_child)
92+
return SingleProperty(name, type, is_child=is_child, tags=tags)
7893

7994

8095
class _DirSelector:
@@ -98,7 +113,8 @@ def load(path):
98113
for name, info in data.items():
99114
if name.startswith("_"):
100115
continue
101-
assert name[0].isupper()
116+
if not name[0].isupper():
117+
raise Error(f"keys in the schema file must be capitalized class names or metadata, got {name}")
102118
cls = classes[name]
103119
for k, v in info.items():
104120
if not k.startswith("_"):
@@ -113,6 +129,10 @@ def load(path):
113129
cls.dir = pathlib.Path(v)
114130
elif k == "_children":
115131
cls.properties.extend(_parse_property(kk, vv, is_child=True) for kk, vv in v.items())
132+
elif k == "_tags":
133+
cls.tags = v
134+
else:
135+
raise Error(f"unknown metadata {k} for class {name}")
116136
if not cls.bases and cls.name != root_class_name:
117137
cls.bases.add(root_class_name)
118138
classes[root_class_name].derived.add(name)

swift/codegen/schema.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ File:
1919
name: string
2020

2121
Locatable:
22-
location: Location
22+
location:
23+
type: Location
24+
_tags: [no_qltest]
2325

2426
Location:
27+
_tags: [ no_qltest ]
2528
file: File
2629
start_line: int
2730
start_column: int
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// generated by {{generator}}
2+
3+
import codeql.swift.elements
4+
import TestUtils
5+
6+
from {{class_name}} x{{#properties}}, {{#is_single}}{{type}}{{/is_single}}{{#is_predicate}}string{{/is_predicate}} {{getter}}{{/properties}}
7+
where toBeTested(x)
8+
{{#properties}}
9+
{{#is_single}}
10+
and {{getter}} = x.{{getter}}()
11+
{{/is_single}}
12+
{{#is_predicate}}
13+
and if x.{{getter}}() then {{getter}} = "yes" else {{getter}} = "no"
14+
{{/is_predicate}}
15+
{{/properties}}
16+
select x{{#properties}}, "{{getter}}:", {{getter}}{{/properties}}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// generated by {{generator}}
2+
3+
After a swift source file is added in this directory and {{generator}} is run again, test queries
4+
will appear and this file will be deleted
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// generated by {{generator}}
2+
3+
import codeql.swift.elements
4+
import TestUtils
5+
6+
{{#property}}
7+
from {{class_name}} x{{#is_repeated}}, int index{{/is_repeated}}
8+
where toBeTested(x)
9+
select x, {{#is_repeated}}index, {{/is_repeated}}x.{{getter}}({{#is_repeated}}index{{/is_repeated}})
10+
{{/property}}

swift/codegen/test/test_ql.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,23 @@ def test_property_indefinite_article(name, expected_getter):
4545
("", False),
4646
("X", True),
4747
])
48-
def test_property_is_plural(plural, expected):
48+
def test_property_is_repeated(plural, expected):
4949
prop = ql.Property("foo", "Foo", "props", ["x"], plural=plural)
5050
assert prop.is_repeated is expected
5151

5252

53+
@pytest.mark.parametrize("is_optional,is_predicate,plural,expected", [
54+
(False, False, None, True),
55+
(False, False, "", True),
56+
(False, False, "X", False),
57+
(True, False, None, False),
58+
(False, True, None, False),
59+
])
60+
def test_property_is_repeated(is_optional, is_predicate, plural, expected):
61+
prop = ql.Property("foo", "Foo", "props", ["x"], plural=plural, is_predicate=is_predicate, is_optional=is_optional)
62+
assert prop.is_single is expected
63+
64+
5365
def test_property_no_plural_no_indefinite_getter():
5466
prop = ql.Property("Prop", "Foo", "props", ["x"])
5567
assert prop.indefinite_getter is None

0 commit comments

Comments
 (0)