Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
libxml2-utils locales pkg-config polkitd procps python3 python3-apt
python3-distutils-extra python3-gi python3-launchpadlib python3-psutil
python3-pyqt5 python3-pytest python3-pytest-cov python3-setuptools
python3-systemd python3-zstandard valgrind xterm
python3-systemd python3-yaql python3-zstandard valgrind xterm
- uses: actions/checkout@v4
- name: Enable German locale
run: sed -i 's/^# de_DE/de_DE/g' /etc/locale.gen && locale-gen
Expand Down Expand Up @@ -102,7 +102,7 @@ jobs:
libxml2-utils locales pkg-config polkitd python3 python3-apt
python3-distutils-extra python3-gi python3-launchpadlib python3-psutil
python3-pyqt5 python3-pytest python3-pytest-cov python3-setuptools
python3-systemd python3-zstandard valgrind xterm
python3-systemd python3-yaql python3-zstandard valgrind xterm
- uses: actions/checkout@v4
- name: Enable German locale
run: sudo sed -i 's/^# de_DE/de_DE/g' /etc/locale.gen && sudo locale-gen
Expand Down
97 changes: 75 additions & 22 deletions apport/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
)


def _check_bug_pattern(report, pattern):
def _check_bug_pattern(report, pattern, *, yaql_engine=None):
"""Check if given report matches the given bug pattern XML DOM node.

Return the bug URL on match, otherwise None.
Expand All @@ -210,41 +210,87 @@
return None

for c in pattern.childNodes:
# regular expression condition
if c.nodeType == xml.dom.Node.ELEMENT_NODE and c.nodeName == "re":
if c.nodeType != xml.dom.Node.ELEMENT_NODE:
continue
if c.nodeName not in ("re", "yaql"):
continue

Check warning on line 216 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L216

Added line #L216 was not covered by tests
try:
key = c.attributes["key"].nodeValue
except KeyError:
continue
if key not in report:
return None
c.normalize()
if not c.hasChildNodes() or c.childNodes[0].nodeType != xml.dom.Node.TEXT_NODE:
continue

Check warning on line 225 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L225

Added line #L225 was not covered by tests

if c.nodeName == "re":
# regular expression condition
regexp = c.childNodes[0].nodeValue
v = report[key]
if isinstance(v, problem_report.CompressedValue):
v = v.get_value()
regexp = regexp.encode("UTF-8")
elif isinstance(v, bytes):
regexp = regexp.encode("UTF-8")
try:
key = c.attributes["key"].nodeValue
except KeyError:
re_c = re.compile(regexp)
except (re.error, TypeError, ValueError):
continue
if key not in report:
if not re_c.search(v):
return None
elif c.nodeName == "yaql":
if yaql_engine is None:
return None
c.normalize()
if c.hasChildNodes() and c.childNodes[0].nodeType == xml.dom.Node.TEXT_NODE:
regexp = c.childNodes[0].nodeValue
v = report[key]
if isinstance(v, problem_report.CompressedValue):
v = v.get_value()
regexp = regexp.encode("UTF-8")
elif isinstance(v, bytes):
regexp = regexp.encode("UTF-8")
import yaql # pylint: disable=import-outside-toplevel

Check warning on line 245 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L245

Added line #L245 was not covered by tests

v = report[key]

Check warning on line 247 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L247

Added line #L247 was not covered by tests
if isinstance(v, problem_report.CompressedValue):
v = v.get_value()
expression = yaql_engine(c.childNodes[0].nodeValue)
try:
fmt = c.attributes["format"].nodeValue
except KeyError:

Check warning on line 253 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L249-L253

Added lines #L249 - L253 were not covered by tests
# Only JSON and YAML are supported for now. Since it is
# possible to parse JSON with a YAML parser, make it the
# default. For JSON, specifying format="json" will result in
# faster parsing though.
fmt = "yaml"

Check warning on line 258 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L258

Added line #L258 was not covered by tests
if fmt == "json":
import json # pylint: disable=import-outside-toplevel

Check warning on line 260 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L260

Added line #L260 was not covered by tests

try:
re_c = re.compile(regexp)
except (re.error, TypeError, ValueError):
continue
if not re_c.search(v):
data = json.loads(v)
except json.JSONDecodeError:

Check warning on line 264 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L263-L264

Added lines #L263 - L264 were not covered by tests
return None
elif fmt == "yaml":
import yaml # pylint: disable=import-outside-toplevel

Check warning on line 267 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L267

Added line #L267 was not covered by tests

try:
data = yaml.safe_load(v)
except yaml.YAMLError:
return None

Check warning on line 272 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L269-L272

Added lines #L269 - L272 were not covered by tests
else:
apport.logging.warning("unsupported format for yaql: %s", fmt)
return None

Check warning on line 275 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L274-L275

Added lines #L274 - L275 were not covered by tests

try:

Check warning on line 277 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L277

Added line #L277 was not covered by tests
if not expression.evaluate(data=data):
return None
except yaql.language.exceptions.YaqlException:
return None

Check warning on line 281 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L279-L281

Added lines #L279 - L281 were not covered by tests

return pattern.attributes["url"].nodeValue


def _check_bug_patterns(report, patterns):
def _check_bug_patterns(report, patterns, *, yaql_engine=None):
try:
dom = xml.dom.minidom.parseString(patterns)
except (xml.parsers.expat.ExpatError, UnicodeEncodeError):
return None

for pattern in dom.getElementsByTagName("pattern"):
url = _check_bug_pattern(report, pattern)
url = _check_bug_pattern(report, pattern, yaql_engine=yaql_engine)
if url:
return url

Expand Down Expand Up @@ -1242,7 +1288,14 @@
if "<title>404 Not Found" in patterns:
return None

url = _check_bug_patterns(self, patterns)
try:
import yaql # pylint: disable=import-outside-toplevel

yaql_engine = yaql.factory.YaqlFactory().create()

Check warning on line 1294 in apport/report.py

View check run for this annotation

Codecov / codecov/patch

apport/report.py#L1294

Added line #L1294 was not covered by tests
except ImportError:
yaql_engine = None

url = _check_bug_patterns(self, patterns, yaql_engine=yaql_engine)
if url:
return url

Expand Down
134 changes: 134 additions & 0 deletions tests/integration/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import atexit
import grp
import importlib
import io
import os
import pathlib
Expand All @@ -19,6 +20,8 @@
from typing import IO
from unittest.mock import MagicMock

import pytest

import apport.packaging
import apport.report
import problem_report
Expand Down Expand Up @@ -1196,6 +1199,137 @@ def test_search_bug_patterns(self) -> None:
"gracefully handles nonexisting URL domain",
)

@pytest.mark.skipif(
not importlib.util.find_spec("yaql"),
reason="YAQL Python module is not installed",
)
def test_search_bug_patterns_yaql_installed(self) -> None:
self._test_search_bug_patterns_yaql(yaql_installed=True)

def test_search_bug_patterns_yaql_not_installed(self) -> None:
with unittest.mock.patch.dict(sys.modules, {"yaql": None}, clear=False):
self._test_search_bug_patterns_yaql(yaql_installed=False)

def _test_search_bug_patterns_yaql(self, yaql_installed: bool) -> None:
# create some test patterns
patterns = textwrap.dedent(
"""\
<?xml version="1.0"?>
<patterns>
<pattern url="http://bugtracker.net/bugs/1">
<re key="Package">subiquity</re>
<yaql key="ProbeData" format="json">
isDict($) and $.get("blockdev", {}).values().count() > 3
</yaql>
</pattern>
<pattern url="http://bugtracker.net/bugs/2">
<re key="Package">subiquity</re>
<yaql key="ProbeData">
isDict($) and $.get("blockdev", {}).values().where(
isDict($) and $.get("ID_PART_TABLE_TYPE") = "unsupported"
).any()
</yaql>
</pattern>
<pattern url="http://bugtracker.net/bugs/3">
<re key="Package">subiquity</re>
<yaql key="CurtinPartitioningConfig" format="yaml">
not $.storage.config.where(
$.get("path") = "/"
).any()
</yaql>
</pattern>
</patterns>"""
).encode()

reports = []
r = apport.report.Report()
r["Package"] = "subiquity"
r["ProbeData"] = (
""" {
"blockdev": {
"/dev/sda": null, "/dev/sda1": null,
"/dev/sda2": null, "/dev/sda3": null
}
} """
)

reports.append(r)

r = apport.report.Report()
r["Package"] = "subiquity"
r["ProbeData"] = (
'{"blockdev": {"/dev/sda1": {"ID_PART_TABLE_TYPE": "unsupported"}}}'
)
reports.append(r)

r = apport.report.Report()
r["Package"] = "subiquity"
r["CurtinPartitioningConfig"] = textwrap.dedent(
"""
storage:
config:
- type: mount
path: /home
"""
)
reports.append(r)

r = apport.report.Report()
r["Package"] = "subiquity"
r["ProbeData"] = (
""" {
"blockdev": {
"/dev/sda": null, "/dev/sda1": null,
}
} """
)
reports.append(r)

r = apport.report.Report()
r["Package"] = "subiquity"
r["ProbeData"] = "{}"
reports.append(r)

r = apport.report.Report()
r["Package"] = "subiquity"
r["ProbeData"] = "{" # <- invalid JSON
reports.append(r)

if yaql_installed:
expected_values = [
("http://bugtracker.net/bugs/1", None),
("http://bugtracker.net/bugs/2", None),
("http://bugtracker.net/bugs/3", None),
(None, "does not match any pattern"),
(None, "does not match empty JSON"),
(None, "does not match invalid JSON"),
]
else:
expected_values = [
(None, "does not match without a YAQL engine") for _ in reports
]

with tempfile.NamedTemporaryFile(prefix="apport-") as bug_pattern:
bug_pattern.write(patterns)
bug_pattern.flush()
pattern_url = f"file://{bug_pattern.name}"

for report, (expected_value, reason) in zip(reports, expected_values):
self.assertEqual(
expected_value, report.search_bug_patterns(pattern_url), reason
)
# Also test with compressed values (only for those who have ProbeData)
if "ProbeData" in report:
compressed = report.copy()
compressed["ProbeData"] = problem_report.CompressedValue(
report["ProbeData"].encode("utf-8")
)
self.assertEqual(
expected_value,
compressed.search_bug_patterns(pattern_url),
reason,
)

def test_add_hooks_info(self) -> None:
# TODO: Split into separate test cases
# pylint: disable=too-many-statements
Expand Down
Loading