PyQt "protected" file dialog

32 Views Asked by At

I'm using PYQt 5 on a Linux based system; Currently I'm in need to let the user insert a USB pen drive and save some file into it, or open some file from it (i.e., a software upgrade or a configuration). Showing a file dialog in which I can save or select a file seems a simple task to do.

But additionally I want the user to browse only into the USB pen drive (i.e. /mnt/myUSB/..) forward and back, but not to go back to the system directory and perform some potentially harmful operation.

Has anyone ever done this?

Doing this opens the dialog in /mnt, but don't prevent the user to browse back to /usr/local/bin or similar ' dangerous' path.

    dialog = QtWidgets.QFileDialog(self) 
    dialog.setDirectory('/mnt')
    dialog.setFileMode(QtWidgets.QFileDialog.AnyFile) 
    dialog.exec_()
1

There are 1 best solutions below

0
musicamante On

It is possible by using some known features of QFileDialog and a few undocumented aspects.

There are two main requirements for this:

  1. set a proxy model that allows to filter the paths that are accessible to the view;
  2. use a non native file dialog, since that's the only way to use a proxy model in the view that shows the file system contents;

The first requirement is done by using setProxyModel() with a QSortFilterProxyModel subclass that implements filterAcceptsRow():

class LimitedAccessProxy(QSortFilterProxyModel):
    def __init__(self, basePath):
        super().__init__()
        self.base = basePath

    def lessThan(self, left, right):
        # required to keep the original order
        return left.row() < right.row()

    def filterAcceptsRow(self, row, parent):
        if row < 0:
            return super().filterAcceptsRow(row, parent)
        path = self.sourceModel().filePath(self.sourceModel().index(row, 0, parent))
        return path.startswith(self.base) or self.base.startswith(path)

This will ensure that only paths that are inside the "base" directory will be shown.

The self.base.startswith(path) part also ensures that all parent directories will be available to the view, which is required in order to access the base path itself: if any of the parent is filtered out, the base path will never be reached.

This is how it can be used:

dialog = QFileDialog()
dialog.setFileMode(QFileDialog.AnyFile)
dialog.setOption(QFileDialog.DontUseNativeDialog)
dialog.setDirectory('/tmp')
dialog.setProxyModel(LimitedAccessProxy('/tmp'))
dialog.open()

Now, this doesn't completely prevent the user to go up in the directory structure. In your specific case this may not be a big of an issue, since the parent directory of /tmp is the root, which doesn't provide write access to standard users.

But, in case the target directory is a child of another, writable directory, that may be an issue.

In order to limit that access, we can check whenever the current directory changes, and prevent the user to ever navigate the parent. To do so we:

  • disable the "up" button when the current path is the "base" one;
  • go "back" in the history whenever the user is able to navigate through a directory that is not allowed (and disable the "forward" button);
  • disable items of the "look in" combo that refer to unwanted paths;
  • override the accept() function so that it doesn't inadvertently accept entered paths manually typed in the file name field;
class RestrictedFileDialog(QFileDialog):
    def __init__(self, path, parent=None):
        super().__init__(parent)
        self.setFileMode(QFileDialog.AnyFile)
        self.setOption(QFileDialog.DontUseNativeDialog)
        self.setDirectory(path)
        if path != QDir.rootPath():
            self.path = path.rstrip(QDir.separator())
            self.proxy = LimitedAccessProxy(self.path)
            self.setProxyModel(self.proxy)
            self.lookInCombo = self.findChild(QComboBox, 'lookInCombo')
            self.backButton = self.findChild(QToolButton, 'backButton')
            self.forwardButton = self.findChild(QToolButton, 'forwardButton')
            self.toParentButton = self.findChild(QToolButton, 'toParentButton')

            self.directoryEntered.connect(self._checkDirectory)
            self.backButton.clicked.connect(self._historyFix)
            self.forwardButton.clicked.connect(self._historyFix)
            self.lookInCombo.model().rowsInserted.connect(self._checkLookInCombo)

    def _checkLookInCombo(self):
        model = self.lookInCombo.model()
        for r in range(model.rowCount()):
            index = model.index(r, 0)
            url = index.data(Qt.UserRole + 1)
            if isinstance(url, QUrl):
                path = url.toLocalFile()
                if not path or not path.startswith(self.path):
                    model.itemFromIndex(index).setEnabled(False)

    def _historyFix(self):
        self._checkDirectory(self.directory().absolutePath())

    def _checkDirectory(self, path):
        isSub = path.startswith(self.path + QDir.separator())
        self.toParentButton.setEnabled(isSub)
        if not isSub and path != self.path:
            self.backButton.click()
            self.forwardButton.setEnabled(False)

    def accept(self):
        files = self.selectedFiles()
        if files:
            info = QFileInfo(files[0])
            if info.absoluteFilePath().startswith(self.path):
                super().accept()


app = QApplication(sys.argv)
dialog = RestrictedFileDialog('/tmp/coldcase')
dialog.open()
sys.exit(app.exec_())

The widgets are safely retrieved because their object names are hardcoded in the QFileDialog ui sources and are consistent along different Qt versions.