# SPDX-License-Identifier: LGPL-2.1-only from contextlib import suppress import copy import enum import logging import typing from PyQt6 import QtCore, QtGui, QtWidgets import setools from . import criteria, exception, models, util, views from .queryupdater import BrowserUpdater, ChildrenData, QueryResultsUpdater # workspace settings keys SETTINGS_NOTES: typing.Final[str] = "notes" SETTINGS_SHOW_NOTES: typing.Final[str] = "show_notes" SETTINGS_SHOW_CRITERIA: typing.Final[str] = "show_criteria" # Show criteria default setting (checked) CRITERIA_DEFAULT_CHECKED: typing.Final[bool] = True # Show notes default setting (unchecked) NOTES_DEFAULT_CHECKED: typing.Final[bool] = False TAB_REGISTRY: typing.Final[dict[str, type["BaseAnalysisTabWidget"]]] = {} Q = typing.TypeVar("Q", bound=setools.PolicyQuery) R = typing.TypeVar("R") # type of results from the query __all__ = ("AnalysisSection", "BaseAnalysisTabWidget", "TableResultTabWidget", "DirectedGraphResultTab", "TAB_REGISTRY") class AnalysisSection(enum.Enum): """Groupings of analysis tabs""" Analysis = 1 Components = 2 General = 3 Labeling = 4 Other = 5 Rules = 6 class TabRegistry(models.typing.MetaclassFix): """ Analysis tab registry metaclass. This registers tabs to be used both for populating the content of the "choose analysis" dialog and also for saving tab/workspace info. """ def __new__(cls, *args, **kwargs): classdef = super().__new__(cls, *args, **kwargs) # Only add classes following the tab protocol to the tab registry. if isinstance(classdef, TabProtocol): clsname = args[0] TAB_REGISTRY[clsname] = classdef return classdef @typing.runtime_checkable class TabProtocol(typing.Protocol): """Protocol for tab widgets, in addition to standard Qt widget methods.""" tab_title: str section: AnalysisSection mlsonly: bool def __init__(self, policy: setools.SELinuxPolicy, /, *, parent: QtWidgets.QWidget | None = None) -> None: ... def handle_permmap_change(self, permmap: setools.PermissionMap) -> None: """Handle permission map changes.""" ... def load(self, settings: dict) -> None: """Load a dictionary of settings.""" ... def save(self) -> dict: """Return a dictionary of settings for this tab.""" ... # # The below base classes have unused __init__ arguments to match the # above protocol. # # pylint: disable=invalid-metaclass class BaseAnalysisTabWidget(QtWidgets.QScrollArea, metaclass=TabRegistry): """ Base class for application top-level analysis tabs. Includes an optional frame for criteria. Store the result widget at in the "results" attribute and it is added to the layout correctly. """ tab_title: typing.ClassVar[str] = "Title not set!" section: typing.ClassVar[AnalysisSection] mlsonly: typing.ClassVar[bool] criteria: tuple[criteria.criteria.CriteriaWidget, ...] perm_map: setools.PermissionMap def __init__(self, _, /, *, enable_criteria: bool = True, enable_browser: bool = False, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.log: typing.Final = logging.getLogger(self.__module__) # # configure scroll area # self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) self.setWidgetResizable(True) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) # # Create top-level widget for the scroll area # sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum) sizePolicy.setHorizontalStretch(1) sizePolicy.setVerticalStretch(1) # Create splitter self.top_widget = QtWidgets.QSplitter(self) self.top_widget.setOrientation(QtCore.Qt.Orientation.Horizontal) self.top_widget.setSizePolicy(sizePolicy) self.setWidget(self.top_widget) # # Build browser # if enable_browser: browser_sizing = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum) browser_sizing.setHorizontalStretch(0) browser_sizing.setVerticalStretch(0) # create browser self.browser = views.SEToolsListView(self.top_widget) self.browser.setSizePolicy(browser_sizing) self.top_widget.addWidget(self.browser) self.top_widget.setCollapsible(self.top_widget.indexOf(self.browser), True) # # Build analysis widget # self.analysis_widget = QtWidgets.QWidget(self.top_widget) self.analysis_widget.setSizePolicy(sizePolicy) self.top_widget.addWidget(self.analysis_widget) self.top_widget.setCollapsible(self.top_widget.indexOf(self.analysis_widget), False) # # Create analysis layout # self.analysis_layout = QtWidgets.QGridLayout(self.analysis_widget) self.analysis_layout.setContentsMargins(6, 6, 6, 6) self.analysis_layout.setSpacing(3) # title and "show" checkboxes title = QtWidgets.QLabel(self.analysis_widget) title.setText(self.tab_title) title.setObjectName("title") sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(title.sizePolicy().hasHeightForWidth()) title.setSizePolicy(sizePolicy) self.analysis_layout.addWidget(title, 0, 0) # spacer between title and "show:" spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) self.analysis_layout.addItem(spacerItem, 0, 1) # "show" label label_2 = QtWidgets.QLabel(self.analysis_widget) label_2.setText("Show:") sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(label_2.sizePolicy().hasHeightForWidth()) label_2.setSizePolicy(sizePolicy) self.analysis_layout.addWidget(label_2, 0, 2) if enable_criteria: # criteria expander checkbox self.criteria_expander = QtWidgets.QCheckBox(self.analysis_widget) self.criteria_expander.setChecked(CRITERIA_DEFAULT_CHECKED) self.criteria_expander.setToolTip( "Show or hide the search criteria (no settings are lost)") self.criteria_expander.setWhatsThis( """ Show or hide the search criteria.
No settings are lost if the criteria is hidden.
""") self.criteria_expander.setText("Criteria") self.analysis_layout.addWidget(self.criteria_expander, 0, 3) # notes expander checkbox self.notes_expander = QtWidgets.QCheckBox(self.analysis_widget) self.notes_expander.setSizePolicy(sizePolicy) self.notes_expander.setToolTip("Show or hide the notes.") self.notes_expander.setWhatsThis( """ Show or hide the notes field.No notes are lost if the notes are hidden.
""") self.notes_expander.setText("Notes") self.notes_expander.setChecked(NOTES_DEFAULT_CHECKED) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) sizePolicy.setHeightForWidth(self.notes_expander.sizePolicy().hasHeightForWidth()) self.analysis_layout.addWidget(self.notes_expander, 0, 4) if enable_criteria: # criteria frame self.criteria_frame = QtWidgets.QFrame(self.analysis_widget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) sizePolicy.setHeightForWidth(self.criteria_frame.sizePolicy().hasHeightForWidth()) self.criteria_frame.setSizePolicy(sizePolicy) self.criteria_frame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) self.criteria_frame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised) self.criteria_frame.setVisible(CRITERIA_DEFAULT_CHECKED) self.criteria_expander.toggled.connect(self.criteria_frame.setVisible) self.criteria_frame_layout = QtWidgets.QGridLayout(self.criteria_frame) self.criteria_frame_layout.setContentsMargins(6, 6, 6, 6) self.criteria_frame_layout.setSpacing(3) self.analysis_layout.addWidget(self.criteria_frame, 1, 0, 1, 5) # Button box at the bottom of the criteria frame. This must be # added to self.criteria_frame_layout by the subclasses, as the # placement is dependent on the criteria widget layout. self.buttonBox = QtWidgets.QDialogButtonBox(self.criteria_frame) self.run_button = QtWidgets.QPushButton( self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowRight), "Run", self.buttonBox) self.run_button.clicked.connect(self.run) self.buttonBox.addButton(self.run_button, QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole) # notes pane self.notes = QtWidgets.QTextEdit(self.analysis_widget) self.notes.setToolTip("Optionally enter notes here.") self.notes.setWhatsThis( """ Query NotesOptionally enter notes about the query and results here. The notes are saved with tab and workspace data.
""" ) self.notes.setPlaceholderText("Enter notes here.") self.notes.setVisible(NOTES_DEFAULT_CHECKED) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) sizePolicy.setHeightForWidth(self.notes.sizePolicy().hasHeightForWidth()) self.notes.setSizePolicy(sizePolicy) self.analysis_layout.addWidget(self.notes, 3, 0, 1, 5) self.notes_expander.toggled.connect(self.notes.setVisible) QtCore.QMetaObject.connectSlotsByName(self.analysis_widget) QtCore.QMetaObject.connectSlotsByName(self) @property def results(self) -> QtWidgets.QWidget: return self._results_widget @results.setter def results(self, widget: QtWidgets.QWidget) -> None: self.analysis_layout.addWidget(widget, 2, 0, 1, 5) self._results_widget = widget def run(self) -> None: """Run the query.""" raise NotImplementedError def query_completed(self, count: int) -> None: """Handle successful query completion.""" raise NotImplementedError def query_failed(self, message: str) -> None: """Handle query failure.""" raise NotImplementedError # @typing.override def style(self) -> QtWidgets.QStyle: """Type-narrowed style() method. Always returns a QStyle.""" style = super().style() assert style, "No style set, this is an SETools bug" # type narrowing return style # # Workspace methods # def handle_permmap_change(self, permmap: setools.PermissionMap) -> None: """Handle permission map changes.""" pass def save(self) -> dict: """Return a dictionary of settings for this tab.""" with suppress(AttributeError): # handle criteria-less tabs errors = [c for c in self.criteria if c.has_errors] if errors: raise exception.TabFieldError("Cannot save due to errors in the criteria.") settings = dict[str, str | bool | list[str]]() settings[SETTINGS_SHOW_NOTES] = self.notes_expander.isChecked() settings[SETTINGS_NOTES] = self.notes.toPlainText() with suppress(AttributeError): settings[SETTINGS_SHOW_CRITERIA] = self.criteria_expander.isChecked() with suppress(AttributeError): for w in self.criteria: w.save(settings) return settings def load(self, settings: dict) -> None: """Load a dictionary of settings.""" with suppress(AttributeError): # handle criteria-less tabs for w in self.criteria: w.load(settings) with suppress(KeyError): self.notes.setText(str(settings[SETTINGS_NOTES])) with suppress(KeyError): self.notes_expander.setChecked(settings[SETTINGS_SHOW_NOTES]) with suppress(KeyError, AttributeError): self.criteria_expander.setChecked(settings[SETTINGS_SHOW_CRITERIA]) class TableResultTabWidget(BaseAnalysisTabWidget, typing.Generic[Q, R]): """ Application top-level analysis tab that provides a QTabWidget with tabs for results in a table and in raw text form. """ # TODO get signals to disable the run button if there are criteria errors. class ResultTab(enum.IntEnum): """ Enumeration of result tabs. 0-indexed to match the tab widget indexing. """ Table = 0 Text = 1 def __init__(self, query: Q, /, *, enable_criteria: bool = True, enable_browser: bool = False, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(query, enable_criteria=enable_criteria, enable_browser=enable_browser, parent=parent) self.query: typing.Final[Q] = query # results as 2 tab self.results = QtWidgets.QTabWidget(self.analysis_widget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.MinimumExpanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(2) sizePolicy.setHeightForWidth(self.results.sizePolicy().hasHeightForWidth()) self.results.setSizePolicy(sizePolicy) # create result tab 1 self.table_results = views.SEToolsTableView(self.results) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.MinimumExpanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.table_results.sizePolicy().hasHeightForWidth()) self.table_results.setSizePolicy(sizePolicy) self.table_results.setSizeAdjustPolicy( QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustIgnored) self.table_results.setAlternatingRowColors(True) self.table_results.setSortingEnabled(True) self.table_results.setWhatsThis( "This tab has the table-based results of the query.") self.results.addTab(self.table_results, "Results") self.results.setTabWhatsThis( TableResultTabWidget.ResultTab.Table, "This tab has the table-based results of the query.") # Set up filter proxy. Subclasses must set the table_results_model # property to fully set this up. self.sort_proxy = QtCore.QSortFilterProxyModel(self.table_results) self.table_results.setModel(self.sort_proxy) self.table_results.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder) # create result tab 2 self.raw_results = QtWidgets.QPlainTextEdit(self.results) self.raw_results.setObjectName("raw_results") sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.MinimumExpanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.raw_results.sizePolicy().hasHeightForWidth()) self.raw_results.setSizePolicy(sizePolicy) self.raw_results.setDocumentTitle("") self.raw_results.setLineWrapMode(QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap) self.raw_results.setReadOnly(True) self.raw_results.setWhatsThis("This tab has plain text results of the query.") self.results.addTab(self.raw_results, "Raw Results") self.results.setTabWhatsThis(TableResultTabWidget.ResultTab.Text, "This tab has plain text results of the query.") self.results.setCurrentIndex(TableResultTabWidget.ResultTab.Table) # set up processing thread self.processing_thread = QtCore.QThread(self.analysis_widget) # create a "busy, please wait" dialog self.busy = QtWidgets.QProgressDialog(self.analysis_widget) self.busy.setModal(True) self.busy.setRange(0, 0) self.busy.setMinimumDuration(0) self.busy.canceled.connect(self.processing_thread.requestInterruption) self.busy.reset() def __del__(self): with suppress(RuntimeError): self.processing_thread.quit() self.processing_thread.wait(5000) @property def table_results_model(self) -> models.SEToolsTableModel: """Return the table results model for this tab.""" return typing.cast(models.SEToolsTableModel, self.sort_proxy.sourceModel()) @table_results_model.setter def table_results_model(self, model: models.SEToolsTableModel) -> None: """Set the table results model for this tab and set up the processing thread for it.""" self.sort_proxy.setSourceModel(model) self.worker = QueryResultsUpdater[Q, R](self.query, table_model=model) self.worker.moveToThread(self.processing_thread) self.worker.raw_line.connect(self.raw_results.appendPlainText) self.worker.finished.connect(self.query_completed) self.worker.finished.connect(self.processing_thread.quit) self.worker.failed.connect(self.query_failed) self.worker.failed.connect(self.processing_thread.quit) self.processing_thread.started.connect(self.worker.update) # # Start/end of processing # def run(self) -> None: """Start processing query.""" errors = [c.title() for c in self.criteria if c.has_errors] if errors: QtWidgets.QMessageBox.critical( self, "Address criteria errors", "Cannot run due to errors in the criteria.", QtWidgets.QMessageBox.StandardButton.Ok) return self.busy.setLabelText("Processing query...") self.busy.show() self.raw_results.clear() self.processing_thread.start() def query_completed(self, count: int) -> None: """Query completed.""" self.log.debug(f"{count} result(s) found.") self.setStatusTip(f"{count} result(s) found.") # 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 column widths are too long, pull back to a reasonable size header = self.table_results.horizontalHeader() assert header, "No header set, this is an SETools bug" # type narrowing self.busy.setLabelText("Resizing very wide columns; GUI may be unresponsive") for i in range(header.count()): if header.sectionSize(i) > 400: header.resizeSection(i, 400) if self.busy.wasCanceled(): break 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(QtGui.QTextCursor.MoveOperation.Start) self.busy.reset() def query_failed(self, message: str) -> None: self.busy.reset() self.setStatusTip(f"Error: {message}.") QtWidgets.QMessageBox.critical( self, "Error", message, QtWidgets.QMessageBox.StandardButton.Ok) DGA = typing.TypeVar("DGA", bound=setools.query.DirectedGraphAnalysis) N = typing.TypeVar("N", bound=setools.policyrep.PolicyObject) # type of nodes in the graph class DirectedGraphResultTab(BaseAnalysisTabWidget, typing.Generic[DGA, R, N]): """ Application top-level analysis tab that provides a QTabWidget with tabs for results in a graph and in raw text form. """ # TODO get signals to disable the run button if there are criteria errors. class ResultTab(enum.IntEnum): """ Enumeration of result tabs. 0-indexed to match the tab widget indexing. """ Graph = 0 Tree = 1 Text = 2 def __init__(self, query: DGA, /, *, enable_criteria: bool = True, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(query, enable_criteria=enable_criteria, enable_browser=False, parent=parent) self.query: typing.Final = query # Create tab widget self.results = QtWidgets.QTabWidget(self.analysis_widget) tw_sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.MinimumExpanding) tw_sizePolicy.setHorizontalStretch(0) tw_sizePolicy.setVerticalStretch(2) tw_sizePolicy.setHeightForWidth(self.results.sizePolicy().hasHeightForWidth()) self.results.setSizePolicy(tw_sizePolicy) # # Create size policy for tabs # sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.MinimumExpanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.results.sizePolicy().hasHeightForWidth()) # # Create graphical tab # self.graphical_scroll = QtWidgets.QScrollArea(self.results) self.graphical_scroll.setSizeAdjustPolicy( QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) self.graphical_scroll.setWidgetResizable(True) self.results.addTab(self.graphical_scroll, "Graphical Results") image_size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding) image_size_policy.setHorizontalStretch(1) image_size_policy.setVerticalStretch(1) image_size_policy.setHeightForWidth(True) self.graphical_results = QtWidgets.QLabel(self.graphical_scroll) self.graphical_results.setObjectName("graphical_results") self.graphical_results.setSizePolicy(image_size_policy) self.graphical_results.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.graphical_results.customContextMenuRequested.connect( self._graphical_results_context_menu) self.graphical_scroll.setWidget(self.graphical_results) # # Create tree browser tab # self.tree_widget = QtWidgets.QWidget(self.results) self.results.addTab(self.tree_widget, "Tree Results") self.results.setTabWhatsThis( DirectedGraphResultTab.ResultTab.Tree, "This tab has the tree-based results of the query.") self.tree_layout = QtWidgets.QHBoxLayout(self.tree_widget) self.tree_widget.setLayout(self.tree_layout) # tree widget self.tree_results = views.SEToolsTreeWidget(self.tree_widget) self.tree_results.setObjectName("tree_results") self.tree_results.setSizeAdjustPolicy( QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustIgnored) self.tree_results.setAlternatingRowColors(True) self.tree_results.setSortingEnabled(True) self.tree_results.setItemsExpandable(True) self.tree_results.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder) self.tree_results.setWhatsThis( "This tab has the tree-based results of the query.") self.tree_results.currentItemChanged.connect(self._selection_changed) self.tree_layout.addWidget(self.tree_results) tree_results_sp = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) tree_results_sp.setHorizontalStretch(1) tree_results_sp.setVerticalStretch(0) tree_results_sp.setHeightForWidth(self.tree_results.sizePolicy().hasHeightForWidth()) self.tree_results.setSizePolicy(sizePolicy) # tree raw results self.tree_raw_results = QtWidgets.QPlainTextEdit(self.results) self.tree_raw_results.setObjectName("raw_results") self.tree_raw_results.setDocumentTitle("") self.tree_raw_results.setLineWrapMode(QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap) self.tree_raw_results.setReadOnly(True) self.tree_raw_results.setWhatsThis("This tab has plain text results of the query.") self.tree_layout.addWidget(self.tree_raw_results) tree_raw_results_sp = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.MinimumExpanding) tree_raw_results_sp.setHorizontalStretch(2) tree_raw_results_sp.setVerticalStretch(0) tree_raw_results_sp.setHeightForWidth( self.tree_raw_results.sizePolicy().hasHeightForWidth()) self.tree_raw_results.setSizePolicy(tree_raw_results_sp) # # Create text result tab # self.raw_results = QtWidgets.QPlainTextEdit(self.results) self.raw_results.setObjectName("raw_results") self.raw_results.setSizePolicy(sizePolicy) self.raw_results.setDocumentTitle(f"{self.tab_title} Text Results") self.raw_results.setLineWrapMode(QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap) self.raw_results.setReadOnly(True) self.raw_results.setWhatsThis("This tab has plain text results of the query.") self.results.addTab(self.raw_results, "Raw Results") self.results.setTabWhatsThis(DirectedGraphResultTab.ResultTab.Text, "This tab has plain text results of the query.") # set initial tab self.results.setCurrentIndex(DirectedGraphResultTab.ResultTab.Graph) # set up processing threads self.processing_thread = QtCore.QThread(self.analysis_widget) self.browser_thread = QtCore.QThread(self.analysis_widget) # create a "busy, please wait" dialog self.busy = QtWidgets.QProgressDialog(self.analysis_widget) self.busy.setModal(True) self.busy.setRange(0, 0) self.busy.setMinimumDuration(0) self.busy.canceled.connect(self.processing_thread.requestInterruption) self.busy.reset() # set up results worker self.worker = QueryResultsUpdater[DGA, R](self.query, graphics_buffer=self.graphical_results) self.worker.moveToThread(self.processing_thread) self.worker.raw_line.connect(self.raw_results.appendPlainText) self.worker.finished.connect(self.query_completed) self.worker.finished.connect(self.processing_thread.quit) self.worker.failed.connect(self.query_failed) self.worker.failed.connect(self.processing_thread.quit) self.processing_thread.started.connect(self.worker.update) # set up browser worker self.browser_worker = BrowserUpdater[DGA, R, N]() self.browser_worker.moveToThread(self.browser_thread) self.browser_worker.result.connect(self._add_browser_children) self.browser_worker.result.connect(self.browser_thread.quit) self.browser_worker.failed.connect(self._browser_worker_failed) self.browser_worker.failed.connect(self.browser_thread.quit) self.browser_thread.started.connect(self.browser_worker.run) def __del__(self): with suppress(RuntimeError): self.processing_thread.quit() self.processing_thread.wait(5000) # # Graphical results methods # def _graphical_results_context_menu(self, pos: QtCore.QPoint) -> None: """Generate context menu for graphical results widget.""" save_action = QtGui.QAction("Save As...", self.graphical_results) save_action.triggered.connect(self._save_graphical_results) menu = QtWidgets.QMenu(self.graphical_results) menu.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) menu.addActions((save_action,)) menu.exec(self.graphical_results.mapToGlobal(pos)) def _save_graphical_results(self) -> None: """Save the graphical results to a file.""" with util.QMessageOnException("Error", "Failed to save graphical results.", log=self.log, parent=self): filename, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Save graphical results", "", "PNG files (*.png);;All files (*)") if filename: if not self.graphical_results.pixmap().save(filename, format="PNG"): # The save method does not raise an exception, so unfortunately # there is no additional info to share with the user. raise RuntimeError(f"Failed to save graphical results to {filename}.") # # Tree browser methods # class ItemData(enum.IntEnum): """ User data enumeration for the QTreeWidgetItems. This is to cleanly store relevant info for the browser. Only the policy object (typically type) will be visible to the user. """ PolicyObject = models.ModelRoles.PolicyObjRole Rules = len(models.ModelRoles) + 1 ChildrenPopulated = len(models.ModelRoles) + 2 Children = len(models.ModelRoles) + 3 def _add_browser_children(self, children: list[tuple[N, list[R]]]) -> None: item = self.tree_results.currentItem() assert item, "No item selected, this is an SETools bug" # type narrowing item.setData(0, self.ItemData.Children, children) for child_type, child_rules in children: child_item = self._create_browser_item(child_type, item, rules=child_rules) item.addChild(child_item) item.setData(0, self.ItemData.ChildrenPopulated, True) self.busy.reset() def _add_root_item(self) -> QtWidgets.QTreeWidgetItem | None: """ Add the root item to the tree widget, if applicable. The implementation must: 1. Create the root item in the tree browser when applicable """ raise NotImplementedError def _browser_worker_failed(self, message: str) -> None: self.busy.reset() self.setStatusTip(f"Error: {message}.") QtWidgets.QMessageBox.critical( self, "Error", message, QtWidgets.QMessageBox.StandardButton.Ok) def _create_browser_item(self, obj: N, /, parent_item: QtWidgets.QTreeWidgetItem | None = None, *, rules: list[R] | None = None, children: ChildrenData | None = None) -> QtWidgets.QTreeWidgetItem: item = QtWidgets.QTreeWidgetItem(parent_item if parent_item else None) item.setText(0, str(obj)) item.setData(0, self.ItemData.PolicyObject, obj) rules = rules if rules else [] item.setData(0, self.ItemData.Rules, rules) item.setData(0, self.ItemData.ChildrenPopulated, children is not None) children = children if children else [] item.setData(0, self.ItemData.Children, children) # add child items child_obj: N child_rules: list[R] for child_obj, child_rules in children: item.addChild(self._create_browser_item(child_obj, item, rules=child_rules)) item.setExpanded(children is not None) self.log.debug(f"Built item for {obj} with {len(children)} children.") return item def _populate_children(self, item: QtWidgets.QTreeWidgetItem) -> None: """Populate the children of the selected item.""" raise NotImplementedError def _selection_changed(self, current_item: QtWidgets.QTreeWidgetItem, previous_item: QtWidgets.QTreeWidgetItem) -> None: """Display the analysis data for the selected index.""" if not current_item: # browser is being reset return self.log.debug(f"Selection changed: {current_item=} {previous_item=}") current_obj: N = current_item.data(0, self.ItemData.PolicyObject) self.log.debug(f"{current_obj} selected in browser.") self.tree_raw_results.clear() parent_item = current_item.parent() if parent_item: rules: list[R] = current_item.data(0, self.ItemData.Rules) self.tree_raw_results.appendPlainText(str(rules)) self.tree_raw_results.moveCursor(QtGui.QTextCursor.MoveOperation.Start) if not current_item.data(0, self.ItemData.ChildrenPopulated): self._populate_children(current_item) # # Start/end of processing # def run(self) -> None: """Start processing query.""" errors = [c for c in self.criteria if c.has_errors] if errors: QtWidgets.QMessageBox.critical( self, "Address criteria errors", "Cannot run due to errors in the criteria.", QtWidgets.QMessageBox.StandardButton.Ok) return self.busy.setLabelText("Processing query...") self.busy.show() self.tree_results.clear() self.tree_raw_results.clear() self.raw_results.clear() self.processing_thread.start() def query_completed(self, count: int) -> None: """Query completed.""" self.log.debug(f"{count} result(s) found.") self.setStatusTip(f"{count} result(s) found.") 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(QtGui.QTextCursor.MoveOperation.Start) self.busy.reset() # copy query to the worker now in case the user reconfigures the query # but has not run it yet. The most recent query run should continue to # be used in the browser. self.browser_worker.query = copy.copy(self.query) self._add_root_item() def query_failed(self, message: str) -> None: self.busy.reset() self.setStatusTip(f"Error: {message}.") QtWidgets.QMessageBox.critical( self, "Error", message, QtWidgets.QMessageBox.StandardButton.Ok) if __name__ == '__main__': import sys import warnings logging.basicConfig(level=logging.DEBUG, format='%(asctime)s|%(levelname)s|%(name)s|%(message)s') warnings.simplefilter("default") p = setools.SELinuxPolicy() q = setools.TERuleQuery(p) pmap = setools.PermissionMap() a = setools.InfoFlowAnalysis(p, pmap) app = QtWidgets.QApplication(sys.argv) mw = QtWidgets.QMainWindow() whatsthis = QtWidgets.QWhatsThis.createAction(mw) mw.menuBar().addAction(whatsthis) # type: ignore[union-attr] mw.setStatusBar(QtWidgets.QStatusBar(mw)) tw = QtWidgets.QTabWidget(mw) mw.setCentralWidget(tw) widget1 = BaseAnalysisTabWidget(None, parent=tw) tw.addTab(widget1, "BaseAnalysisTabWidget w/criteria") widget2 = BaseAnalysisTabWidget(None, enable_criteria=False, parent=tw) tw.addTab(widget2, "BaseAnalysisTabWidget w/o criteria") widget3 = TableResultTabWidget[setools.TERuleQuery, setools.AnyTERule](q, parent=tw) tw.addTab(widget3, "TableResultTabWidget") widget4 = DirectedGraphResultTab[setools.InfoFlowAnalysis, setools.Type, setools.AVRule](a, parent=tw) tw.addTab(widget4, "GraphResultTabWidget w/criteria") mw.resize(1024, 768) mw.show() rc = app.exec() sys.exit(rc)