From f3e883887d9497dcf72474f6d08a5630bec3b79a Mon Sep 17 00:00:00 2001 From: Chris PeBenito Date: Thu, 30 Nov 2023 16:51:02 -0500 Subject: [PATCH] Add new CommonQueyTab implementation. Signed-off-by: Chris PeBenito --- setoolsgui/apol.py | 1 + setoolsgui/apol/commonquery.py | 217 --------- setoolsgui/apol/commonquery.ui | 518 ---------------------- setoolsgui/widgets/commonquery.py | 79 ++++ setoolsgui/widgets/criteria/__init__.py | 1 + setoolsgui/widgets/criteria/common.py | 59 +++ setoolsgui/widgets/details/__init__.py | 1 + setoolsgui/widgets/details/common.py | 44 ++ setoolsgui/widgets/models/common.py | 8 + tests-gui/conftest.py | 1 + tests-gui/widgets/criteria/test_common.py | 24 + tests-gui/widgets/test_commonquery.py | 56 +++ 12 files changed, 274 insertions(+), 735 deletions(-) delete mode 100644 setoolsgui/apol/commonquery.py delete mode 100644 setoolsgui/apol/commonquery.ui create mode 100644 setoolsgui/widgets/commonquery.py create mode 100644 setoolsgui/widgets/criteria/common.py create mode 100644 setoolsgui/widgets/details/common.py create mode 100644 tests-gui/widgets/criteria/test_common.py create mode 100644 tests-gui/widgets/test_commonquery.py diff --git a/setoolsgui/apol.py b/setoolsgui/apol.py index 7827c2d0..6d2b8676 100644 --- a/setoolsgui/apol.py +++ b/setoolsgui/apol.py @@ -20,6 +20,7 @@ # will init the tab registry in widgets.tab for apol's analyses. # pylint: disable=unused-import from .widgets import (boolquery, + commonquery, constraintquery, fsusequery, genfsconquery, diff --git a/setoolsgui/apol/commonquery.py b/setoolsgui/apol/commonquery.py deleted file mode 100644 index e90ec543..00000000 --- a/setoolsgui/apol/commonquery.py +++ /dev/null @@ -1,217 +0,0 @@ -# Copyright 2016, Tresys Technology, LLC -# -# SPDX-License-Identifier: LGPL-2.1-only -# -# - -import logging -from contextlib import suppress - -from PyQt5.QtCore import Qt, QSortFilterProxyModel, QStringListModel, QThread -from PyQt5.QtGui import QPalette, QTextCursor -from PyQt5.QtWidgets import QCompleter, QHeaderView, QMessageBox, QProgressDialog -from setools import CommonQuery - -from ..logtosignal import LogHandlerToSignal -from ..models import SEToolsListModel, invert_list_selection -from ..commonmodel import CommonTableModel, common_detail -from .analysistab import AnalysisSection, AnalysisTab -from .exception import TabFieldError -from .queryupdater import QueryResultsUpdater -from .workspace import load_checkboxes, load_lineedits, load_listviews, load_textedits, \ - save_checkboxes, save_lineedits, save_listviews, save_textedits - - -class CommonQueryTab(AnalysisTab): - - """Common browser and query tab.""" - - section = AnalysisSection.Components - tab_title = "Common Permission Sets" - mlsonly = False - - def __init__(self, parent, policy, perm_map): - super(CommonQueryTab, self).__init__(parent) - self.log = logging.getLogger(__name__) - self.policy = policy - self.query = CommonQuery(policy) - self.setupUi() - - def __del__(self): - with suppress(RuntimeError): - self.thread.quit() - self.thread.wait(5000) - - logging.getLogger("setools.commonquery").removeHandler(self.handler) - - def setupUi(self): - self.load_ui("apol/commonquery.ui") - - # populate commons list - self.common_model = SEToolsListModel(self) - self.common_model.item_list = sorted(c for c in self.policy.commons()) - self.commons.setModel(self.common_model) - - # populate perm list - self.perms_model = SEToolsListModel(self) - perms = set() - for com in self.policy.commons(): - perms.update(com.perms) - self.perms_model.item_list = sorted(perms) - self.perms.setModel(self.perms_model) - - # set up results - self.table_results_model = CommonTableModel(self) - self.sort_proxy = QSortFilterProxyModel(self) - self.sort_proxy.setSourceModel(self.table_results_model) - self.table_results.setModel(self.sort_proxy) - self.table_results.sortByColumn(0, Qt.AscendingOrder) - - # setup indications of errors - self.errors = set() - self.orig_palette = self.name.palette() - self.error_palette = self.name.palette() - self.error_palette.setColor(QPalette.Base, Qt.red) - self.clear_name_error() - - # set up processing thread - self.thread = QThread() - self.worker = QueryResultsUpdater(self.query, self.table_results_model) - self.worker.moveToThread(self.thread) - self.worker.raw_line.connect(self.raw_results.appendPlainText) - self.worker.finished.connect(self.update_complete) - self.worker.finished.connect(self.thread.quit) - self.thread.started.connect(self.worker.update) - - # create a "busy, please wait" dialog - self.busy = QProgressDialog(self) - self.busy.setModal(True) - self.busy.setRange(0, 0) - self.busy.setMinimumDuration(0) - self.busy.canceled.connect(self.thread.requestInterruption) - self.busy.reset() - - # update busy dialog from query INFO logs - self.handler = LogHandlerToSignal() - self.handler.message.connect(self.busy.setLabelText) - logging.getLogger("setools.commonquery").addHandler(self.handler) - - # Ensure settings are consistent with the initial .ui state - self.set_name_regex(self.name_regex.isChecked()) - self.notes.setHidden(not self.notes_expander.isChecked()) - - # connect signals - self.commons.doubleClicked.connect(self.get_detail) - self.commons.get_detail.triggered.connect(self.get_detail) - self.name.textEdited.connect(self.clear_name_error) - self.name.editingFinished.connect(self.set_name) - self.name_regex.toggled.connect(self.set_name_regex) - self.perms.selectionModel().selectionChanged.connect(self.set_perms) - self.invert_perms.clicked.connect(self.invert_perms_selection) - self.buttonBox.clicked.connect(self.run) - - # - # Class browser - # - def get_detail(self): - # .ui is set for single item selection. - index = self.commons.selectedIndexes()[0] - item = self.common_model.data(index, Qt.UserRole) - - self.log.debug("Generating detail window for {0}".format(item)) - common_detail(self, item) - - # - # Name criteria - # - def clear_name_error(self): - self.clear_criteria_error(self.name, "Match the common name.") - - def set_name(self): - try: - self.query.name = self.name.text() - except Exception as ex: - self.log.error("Common name error: {0}".format(ex)) - self.set_criteria_error(self.name, ex) - - def set_name_regex(self, state): - self.log.debug("Setting name_regex {0}".format(state)) - self.query.name_regex = state - self.clear_name_error() - self.set_name() - - # - # Permissions criteria - # - def set_perms(self): - selected_perms = [] - for index in self.perms.selectionModel().selectedIndexes(): - selected_perms.append(self.perms_model.data(index, Qt.UserRole)) - - self.query.perms = selected_perms - - def invert_perms_selection(self): - invert_list_selection(self.perms.selectionModel()) - - # - # Save/Load tab - # - def save(self): - """Return a dictionary of settings.""" - if self.errors: - raise TabFieldError("Field(s) are in error: {0}". - format(" ".join(o.objectName() for o in self.errors))) - - settings = {} - save_checkboxes(self, settings, ["criteria_expander", "notes_expander", "name_regex", - "perms_equal"]) - save_lineedits(self, settings, ["name"]) - save_listviews(self, settings, ["perms"]) - save_textedits(self, settings, ["notes"]) - return settings - - def load(self, settings): - load_checkboxes(self, settings, ["criteria_expander", "notes_expander", "name_regex", - "perms_equal"]) - load_lineedits(self, settings, ["name"]) - load_listviews(self, settings, ["perms"]) - load_textedits(self, settings, ["notes"]) - - # - # Results runner - # - def run(self, button): - # right now there is only one button. - self.query.perms_equal = self.perms_equal.isChecked() - - # start processing - self.busy.setLabelText("Processing query...") - self.busy.show() - self.raw_results.clear() - self.thread.start() - - def update_complete(self, count): - self.log.info("{0} common(s) found.".format(count)) - - # update sizes/location of result displays - if not self.busy.wasCanceled(): - self.busy.setLabelText("Resizing the result table's columns; GUI may be unresponsive") - self.busy.repaint() - self.table_results.resizeColumnsToContents() - # If the permissions column width is too long, pull back - # to a reasonable size - header = self.table_results.horizontalHeader() - if header.sectionSize(1) > 400: - header.resizeSection(1, 400) - - if not self.busy.wasCanceled(): - self.busy.setLabelText("Resizing the result table's rows; GUI may be unresponsive") - self.busy.repaint() - self.table_results.resizeRowsToContents() - - if not self.busy.wasCanceled(): - self.busy.setLabelText("Moving the raw result to top; GUI may be unresponsive") - self.busy.repaint() - self.raw_results.moveCursor(QTextCursor.Start) - - self.busy.reset() diff --git a/setoolsgui/apol/commonquery.ui b/setoolsgui/apol/commonquery.ui deleted file mode 100644 index 9f893b71..00000000 --- a/setoolsgui/apol/commonquery.ui +++ /dev/null @@ -1,518 +0,0 @@ - - - CommonQueryTab_ui - - - - 0 - 0 - 774 - 846 - - - - QAbstractScrollArea::AdjustToContents - - - true - - - - - 0 - 0 - 772 - 844 - - - - - 0 - 0 - - - - - - - - 0 - 0 - - - - - 16777215 - 20 - - - - - 11 - 75 - true - - - - Common Permission Sets - - - - - - - - 0 - 80 - - - - Optionally enter notes here about the query. - - - Enter notes here. - - - - - - - Qt::Horizontal - - - - 440 - 20 - - - - - - - - Show: - - - - - - - Show or hide the search criteria (no settings are lost) - - - Criteria - - - true - - - - - - - Show or hide the notes field (no data is lost) - - - Notes - - - - - - - - 0 - 1 - - - - Qt::Horizontal - - - - Common Browser - - - - - - - - - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Search Criteria - - - - - - Permission Set - - - - - - - 0 - 0 - - - - - 250 - 16777215 - - - - A matching common will have the selected permissions. - - - QAbstractItemView::ExtendedSelection - - - - - - - Clear - - - - - - - A matching common will a permission set equal to the selected permissions. - - - Equal - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Invert - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - QDialogButtonBox::Apply - - - - - - - - 16777215 - 120 - - - - Name - - - - 6 - - - 6 - - - 6 - - - 6 - - - 3 - - - - - - 0 - 0 - - - - - 150 - 20 - - - - - 250 - 16777215 - - - - - - - - Use regular expressions to match the role's name. - - - Regex - - - - - - - - - - - - - - 0 - 1 - - - - 0 - - - - - 0 - 0 - - - - Results - - - - 6 - - - 6 - - - 6 - - - 6 - - - - - - 0 - 0 - - - - QAbstractScrollArea::AdjustIgnored - - - true - - - true - - - - - - - - - 0 - 0 - - - - Raw Results - - - - 6 - - - 6 - - - 6 - - - 6 - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - Monospace - - - - - - - QPlainTextEdit::NoWrap - - - true - - - - - - - - - - - - - splitter - notes - label - label_2 - horizontalSpacer - criteria_expander - notes_expander - - - - - SEToolsTableView - QTableView -
setoolsgui/tableview.h
-
- - GetDetailsListView - QListView -
setoolsgui/getdetailslist.h
-
-
- - criteria_expander - notes_expander - commons - name - name_regex - perms - clear_perms - invert_perms - perms_equal - results_frame - table_results - raw_results - notes - - - - - notes_expander - toggled(bool) - notes - setVisible(bool) - - - 732 - 20 - - - 386 - 754 - - - - - criteria_expander - toggled(bool) - criteria_frame - setVisible(bool) - - - 583 - 20 - - - 496 - 226 - - - - - clear_perms - clicked() - perms - clearSelection() - - - 592 - 216 - - - 442 - 276 - - - - -
diff --git a/setoolsgui/widgets/commonquery.py b/setoolsgui/widgets/commonquery.py new file mode 100644 index 00000000..ea80de9d --- /dev/null +++ b/setoolsgui/widgets/commonquery.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: LGPL-2.1-only + +from PyQt6 import QtWidgets +import setools + +from . import criteria, models, tab + +__all__ = ("CommonQueryTab",) + + +class CommonQueryTab(tab.TableResultTabWidget): + + """A common permission set query.""" + + section = tab.AnalysisSection.Components + tab_title = "Common Permision Sets" + mlsonly = False + + def __init__(self, policy: setools.SELinuxPolicy, _, /, *, + parent: QtWidgets.QWidget | None = None) -> None: + + super().__init__(setools.CommonQuery(policy), None, enable_criteria=True, + enable_browser=True, parent=parent) + + self.setWhatsThis("Search common permission sets in an SELinux policy.") + + # + # Set up criteria widgets + # + name = criteria.CommonName("Name", self.query, "name", enable_regex=True, + parent=self.criteria_frame) + name.setToolTip("Search for common permission sets by name.") + name.setWhatsThis("

Search for common permission set by name.

") + + perms = criteria.PermissionCriteriaWidget("Permissions", self.query, "perms", + enable_equal=True) + perms.setToolTip("Search for common permission sets by permissions.") + perms.setWhatsThis("

Search for common permission set by permissions.

") + + # Add widgets to layout + self.criteria_frame_layout.addWidget(name, 0, 0, 1, 1) + self.criteria_frame_layout.addWidget(perms, 0, 1, 1, 1) + self.criteria_frame_layout.addWidget(self.buttonBox, 1, 0, 1, 2) + + # Save widget references + self.criteria = (name, perms) + + # Set result table's model + self.table_results_model = models.CommonTable(self.table_results) + + # + # Set up browser + # + self.browser.setModel(models.CommonTable(self.browser, + data=sorted(self.query.policy.commons()))) + + +if __name__ == '__main__': + import sys + import warnings + import pprint + import logging + + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s|%(levelname)s|%(name)s|%(message)s') + warnings.simplefilter("default") + + app = QtWidgets.QApplication(sys.argv) + mw = QtWidgets.QMainWindow() + widget = CommonQueryTab(setools.SELinuxPolicy(), mw) + mw.setCentralWidget(widget) + mw.resize(1280, 1024) + whatsthis = QtWidgets.QWhatsThis.createAction(mw) + mw.menuBar().addAction(whatsthis) # type: ignore[union-attr] + mw.setStatusBar(QtWidgets.QStatusBar(mw)) + mw.show() + rc = app.exec() + pprint.pprint(widget.save()) + sys.exit(rc) diff --git a/setoolsgui/widgets/criteria/__init__.py b/setoolsgui/widgets/criteria/__init__.py index f3dad5f9..17da499c 100644 --- a/setoolsgui/widgets/criteria/__init__.py +++ b/setoolsgui/widgets/criteria/__init__.py @@ -4,6 +4,7 @@ from .boolean import * from .comboenum import * +from .common import * from .constraintype import * from .context import * from .fsuseruletype import * diff --git a/setoolsgui/widgets/criteria/common.py b/setoolsgui/widgets/criteria/common.py new file mode 100644 index 00000000..7e9a2f1e --- /dev/null +++ b/setoolsgui/widgets/criteria/common.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: LGPL-2.1-only + +from PyQt6 import QtCore, QtWidgets +import setools + +from .criteria import OptionsPlacement +from .name import NameCriteriaWidget + +# Regex for exact matches to roles +VALIDATE_EXACT = r"[A-Za-z0-9._-]*" + +__all__ = ("CommonName",) + + +class CommonName(NameCriteriaWidget): + + """ + Widget providing a QLineEdit that saves the input to the attributes + of the specified query. This supports inputs of common names. + """ + + indirect_toggled = QtCore.pyqtSignal(bool) + + def __init__(self, title: str, query: setools.PolicyQuery, attrname: str, /, *, + parent: QtWidgets.QWidget | None = None, + options_placement: OptionsPlacement = OptionsPlacement.RIGHT, + required: bool = False, enable_regex: bool = True): + + # Create completion list + completion = list[str](r.name for r in query.policy.commons()) + + super().__init__(title, query, attrname, completion, VALIDATE_EXACT, + enable_regex=enable_regex, required=required, parent=parent, + options_placement=options_placement) + + +if __name__ == '__main__': + import sys + import logging + import warnings + + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s|%(levelname)s|%(name)s|%(message)s') + warnings.simplefilter("default") + + q = setools.CommonQuery(setools.SELinuxPolicy()) + + app = QtWidgets.QApplication(sys.argv) + mw = QtWidgets.QMainWindow() + widget = CommonName("Test Common", q, "name", parent=mw) + widget.setToolTip("test tooltip") + widget.setWhatsThis("test whats this") + mw.setCentralWidget(widget) + mw.resize(widget.size()) + whatsthis = QtWidgets.QWhatsThis.createAction(mw) + mw.menuBar().addAction(whatsthis) # type: ignore[union-attr] + mw.setStatusBar(QtWidgets.QStatusBar(mw)) + mw.show() + sys.exit(app.exec()) diff --git a/setoolsgui/widgets/details/__init__.py b/setoolsgui/widgets/details/__init__.py index 0ed9be27..454ffab6 100644 --- a/setoolsgui/widgets/details/__init__.py +++ b/setoolsgui/widgets/details/__init__.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: LGPL-2.1-only from .boolean import * +from .common import * from .context import * from .objclass import * from .role import * diff --git a/setoolsgui/widgets/details/common.py b/setoolsgui/widgets/details/common.py new file mode 100644 index 00000000..9afab36c --- /dev/null +++ b/setoolsgui/widgets/details/common.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: LGPL-2.1-only +from PyQt6 import QtGui, QtWidgets +import setools + +from . import util + +__all__ = ('common_detail', 'common_detail_action', 'common_tooltip') + + +def common_detail(common: setools.Common, parent: QtWidgets.QWidget | None = None) -> None: + """Display a dialog with common details.""" + + util.display_object_details( + f"{common} Details", + f""" +

Common Name

+

{common}

+ +

Permissions ({len(common.perms)})

+ + """, + parent) + + +def common_detail_action(common: setools.Common, + parent: QtWidgets.QWidget | None = None) -> QtGui.QAction: + """Return a QAction that, when triggered, opens a common detail popup.""" + a = QtGui.QAction(f"Properties of {common}") + a.triggered.connect(lambda x: common_detail(common, parent)) + return a + + +def common_tooltip(common: setools.Common) -> str: + """Return tooltip text for this common.""" + nperms = len(common.perms) + if nperms == 0: + return f"{common} is a common permission set with no permissions defined." + elif nperms > 5: + return f"{common} is a common permission set with {nperms} permissions defined." + else: + return f"{common} is a common permission set with permissions: " \ + f"{', '.join(common.perms)}" diff --git a/setoolsgui/widgets/models/common.py b/setoolsgui/widgets/models/common.py index fda1e7c6..3aa0fee0 100644 --- a/setoolsgui/widgets/models/common.py +++ b/setoolsgui/widgets/models/common.py @@ -6,7 +6,9 @@ from PyQt6 import QtCore import setools +from . import modelroles from .table import SEToolsTableModel +from .. import details __all__ = ("CommonTable",) @@ -33,4 +35,10 @@ def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.ItemDataRole.Dis case 1: return ", ".join(sorted(item.perms)) + case modelroles.ContextMenuRole: + return (details.common_detail_action(item), ) + + case QtCore.Qt.ItemDataRole.ToolTipRole: + return details.common_tooltip(item) + return super().data(index, role) diff --git a/tests-gui/conftest.py b/tests-gui/conftest.py index 202191be..beb974a4 100644 --- a/tests-gui/conftest.py +++ b/tests-gui/conftest.py @@ -62,6 +62,7 @@ def mock_policy() -> Mock: policy = Mock(setools.SELinuxPolicy) policy.bools.return_value = (foo_bool, bar_bool) policy.classes.return_value = (foo_class, bar_class) + policy.commons.return_value = (common,) policy.roles.return_value = (foo_r, bar_r) policy.types.return_value = (foo_t, bar_t) policy.typeattributes.return_value = (fooattr, barattr) diff --git a/tests-gui/widgets/criteria/test_common.py b/tests-gui/widgets/criteria/test_common.py new file mode 100644 index 00000000..1f9dded2 --- /dev/null +++ b/tests-gui/widgets/criteria/test_common.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: GPL-2.0-only +from PyQt6 import QtCore +import pytest +from pytestqt.qtbot import QtBot + +from setoolsgui.widgets import criteria + + +@pytest.fixture +def widget(mock_query, request: pytest.FixtureRequest, qtbot: QtBot) -> criteria.CommonName: + """Pytest fixture to set up the widget.""" + marker = request.node.get_closest_marker("obj_args") + kwargs = marker.kwargs if marker else {} + w = criteria.CommonName(request.node.name, mock_query, "name", **kwargs) + qtbot.addWidget(w) + w.show() + return w + + +def test_base_settings(widget: criteria.CommonName, mock_query) -> None: + """Test base properties of widget.""" + model = widget.criteria.completer().model() + assert isinstance(model, QtCore.QStringListModel) + assert sorted(c.name for c in mock_query.policy.commons()) == model.stringList() diff --git a/tests-gui/widgets/test_commonquery.py b/tests-gui/widgets/test_commonquery.py new file mode 100644 index 00000000..5995c747 --- /dev/null +++ b/tests-gui/widgets/test_commonquery.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: GPL-2.0-only +import typing + +from PyQt6 import QtWidgets +import pytest +from pytestqt.qtbot import QtBot + +import setools +from setoolsgui.widgets.commonquery import CommonQueryTab + + +@pytest.fixture +def widget(mock_policy, request: pytest.FixtureRequest, qtbot: QtBot) -> CommonQueryTab: + """Pytest fixture to set up the widget.""" + marker = request.node.get_closest_marker("obj_args") + kwargs = marker.kwargs if marker else {} + w = CommonQueryTab(mock_policy, None, **kwargs) + qtbot.addWidget(w) + w.show() + return w + + +def test_docs(widget: CommonQueryTab) -> None: + """Check that docs are provided for the widget.""" + assert widget.whatsThis() + assert widget.table_results.whatsThis() + assert widget.raw_results.whatsThis() + + for w in widget.criteria: + assert w.toolTip() + assert w.whatsThis() + + results = typing.cast(QtWidgets.QTabWidget, widget.results) + for index in range(results.count()): + assert results.tabWhatsThis(index) + + +def test_layout(widget: CommonQueryTab) -> None: + """Test the layout of the criteria frame.""" + name, perms = widget.criteria + + assert widget.criteria_frame_layout.columnCount() == 2 + assert widget.criteria_frame_layout.rowCount() == 2 + assert widget.criteria_frame_layout.itemAtPosition(0, 0).widget() == name + assert widget.criteria_frame_layout.itemAtPosition(0, 1).widget() == perms + assert widget.criteria_frame_layout.itemAtPosition(1, 0).widget() == widget.buttonBox + assert widget.criteria_frame_layout.itemAtPosition(1, 1).widget() == widget.buttonBox + + +def test_criteria_mapping(widget: CommonQueryTab) -> None: + """Test that widgets save to the correct query fields.""" + name, state = widget.criteria + + assert isinstance(widget.query, setools.CommonQuery) + assert name.attrname == "name" + assert state.attrname == "perms"