#!/usr/bin/python3 -su

## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

# pylint: disable=broad-exception-caught

## Notes for reviewers:
##
## * Local denial-of-service is out of scope. There are easier ways for
##   malware to pop up lots of windows or consume huge amounts of RAM.
## * The shim's primary purpose is to ensure that files aren't accidentally
##   opened when trying to view the contents of a directory. We do not care if
##   the exact directory that exists at launch time is the directory that gets
##   opened when the user chooses to open it. Therefore replacement attacks
##   involving swapping one directory with another or with a symlink to a
##   directory are out of scope.
## * Python version lower than 3.13.5 are out of scope.

"""
fm_shim_frontend.py - Prompts the user if they want to open one or more
directories in the default file manager.
"""

import sys
import os
import signal
import subprocess
import traceback
import urllib.parse
from pathlib import Path
from typing import NoReturn, cast
from types import FrameType

from PyQt5.QtCore import QTimer, QObject, QEvent, Qt
from PyQt5.QtGui import QFontDatabase, QBrush, QColor
from PyQt5.QtWidgets import (
    QApplication,
    QCheckBox,
    QDialog,
    QLabel,
    QListWidget,
    QListWidgetItem,
    QPushButton,
    QSizePolicy,
    QTextEdit,
    QVBoxLayout,
    QHBoxLayout,
    QWidget,
    QScrollBar,
    QLayout,
    QMessageBox,
)

from stdisplay.stdisplay import stdisplay


class WidgetShowBinding(QObject):
    """
    Detects when a widget is shown or hidden, and shows or hides another
    widget in response.
    """

    def __init__(
        self, bound_widget: QWidget, parent: QObject | None = None
    ) -> None:
        """
        ScrollBarEventFilter constructor.
        """

        super().__init__(parent)

        self.bound_widget = bound_widget

    # pylint: disable=invalid-name, unused-argument
    def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool:
        """
        Shows or hides the bound widget when the binding widget is shown or
        hidden.
        """

        if event is None:
            return False

        # pylint: disable=no-member
        do_recalc_layout: bool = False
        if event.type() == QEvent.Type.Show:
            self.bound_widget.show()
            do_recalc_layout = True
        # pylint: disable=no-member
        elif event.type() == QEvent.Type.Hide:
            self.bound_widget.hide()
            do_recalc_layout = True
        if do_recalc_layout:
            parent_object: QObject | None = self.bound_widget.parent()
            assert parent_object is not None
            parent_widget: QWidget = cast(QWidget, parent_object)
            parent_layout: QLayout | None = parent_widget.layout()
            assert parent_layout is not None
            parent_layout.update()
        return False


class AnalyzePathsWindow(QDialog):
    """
    Helper window for analyzing unsafe characters in paths.
    """

    def __init__(
        self, dir_list: list[Path], parent: QDialog | None = None
    ) -> None:
        """
        AnalyzePathsWindow constructor.
        """

        super().__init__(parent)

        self.dir_list: list[Path] = dir_list
        self.has_unsafe_chars_list: list[bool] = []

        self.core_layout: QVBoxLayout = QVBoxLayout()
        self.dir_list_view: QListWidget = QListWidget()
        self.dir_list_view.resize(1, 50)
        # pylint: disable=no-member
        self.dir_list_view.setSizePolicy(
            QSizePolicy.Policy.Expanding,
            QSizePolicy.Policy.Fixed,
        )
        self.dir_list_view.currentRowChanged.connect(
            self.populate_unicode_show_view
        )
        self.core_layout.addWidget(self.dir_list_view)

        self.unicode_show_view: QTextEdit = QTextEdit()
        self.unicode_show_view.setReadOnly(True)
        # pylint: disable=no-member
        self.unicode_show_view.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
        # pylint: disable=no-member
        self.unicode_show_view.setCurrentFont(
            QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
        )
        self.core_layout.addWidget(self.unicode_show_view)

        self.button_layout: QHBoxLayout = QHBoxLayout()
        self.button_layout.addStretch()
        self.done_button: QPushButton = QPushButton("Done")
        self.done_button.clicked.connect(self.accept)
        self.button_layout.addWidget(self.done_button)
        self.core_layout.addLayout(self.button_layout)

        self.setLayout(self.core_layout)
        self.setWindowTitle("Analyze Paths")
        self.resize(690, 500)

        for dir_path in self.dir_list:
            qlwi: QListWidgetItem = QListWidgetItem(
                stdisplay(str(dir_path), sgr=-1)
            )
            if any(
                (not 0x20 <= ord(path_char) <= 0x7E)
                for path_char in str(dir_path)
            ):
                qlwi.setBackground(QBrush(QColor(255, 128, 128)))
                self.has_unsafe_chars_list.append(True)
            else:
                self.has_unsafe_chars_list.append(False)
            self.dir_list_view.addItem(qlwi)

        self.populate_unicode_show_view(-1)

    def populate_unicode_show_view(self, path_idx: int) -> None:
        """
        Runs the path through unicode-show and populates info about it in the
        unicode_show_view widget.
        """

        if path_idx < 0 or path_idx >= len(self.dir_list):
            self.unicode_show_view.setPlainText(
                "Select a file path to analyze above.\n"
                + "Paths highlighted in red contain potentially unsafe "
                + "characters."
            )
            return

        if self.has_unsafe_chars_list[path_idx]:
            try:
                unicode_show_output: str = subprocess.run(
                    ["/usr/bin/unicode-show"],
                    input=str(self.dir_list[path_idx]),
                    capture_output=True,
                    check=False,
                    encoding="utf-8",
                ).stdout
                self.unicode_show_view.setPlainText(unicode_show_output)
            except Exception:
                traceback.print_exc(file=sys.stderr)
                self.unicode_show_view.setPlainText(
                    "Unable to check this path for unsafe chars!\n"
                    + "See logs in 'journalctl --user --boot fm-shim.service' "
                    + "for more information."
                )
        else:
            self.unicode_show_view.setPlainText(
                "No unsafe chars found in the selected path."
            )


# pylint: disable=too-many-instance-attributes
class FmShimWindow(QDialog):
    """
    Core fm_shim_frontend window.
    """

    # pylint: disable=too-many-statements
    def __init__(
        self, dir_list: list[Path], parent: QDialog | None = None
    ) -> None:
        """
        FmShimWindow constructor.
        """

        super().__init__(parent)

        self.dir_list: list[Path] = dir_list

        self.core_layout: QVBoxLayout = QVBoxLayout()
        self.head_label: QLabel = QLabel(
            "An application has requested to open the following directories "
            + "with the default file manager:"
        )
        self.core_layout.addWidget(self.head_label)

        self.dir_list_view: QTextEdit = QTextEdit()
        self.dir_list_view.setReadOnly(True)
        # pylint: disable=no-member
        self.dir_list_view.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
        # pylint: disable=no-member
        self.dir_list_view.setCurrentFont(
            QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
        )
        self.core_layout.addWidget(self.dir_list_view)

        self.sanitized_text_label: QLabel = QLabel(
            "Note: Any spaces in the above filenames are displayed as "
            + '"middle dot" (\N{MIDDLE DOT}) characters. Potentially '
            + "unsafe characters are displayed as underscores (_)."
        )
        self.sanitized_text_label.setWordWrap(True)
        self.sanitized_text_label.setVisible(False)
        self.core_layout.addWidget(self.sanitized_text_label)

        self.unsafe_text_label: QLabel = QLabel(
            '<font color="red">WARNING:</font> Some of the listed paths '
            + "contain Unicode or ASCII control characters. These can be "
            + 'malicious. Use the "Analyze Paths" button for more details. '
            + 'If you are sure the text is safe, check "Display raw path '
            + 'text" below.'
        )
        self.unsafe_text_label.setWordWrap(True)
        self.unsafe_text_label.setVisible(False)
        self.core_layout.addWidget(self.unsafe_text_label)

        horiz_scroll_bar: QScrollBar | None = (
            self.dir_list_view.horizontalScrollBar()
        )
        assert horiz_scroll_bar is not None
        self.horiz_overflow_warning_label: QLabel = QLabel(
            '<font color="red">WARNING:</font> Some of the paths listed '
            + "above are too long to display all at once. Double-check that "
            + "the paths do not look suspicious."
        )
        self.horiz_overflow_warning_label.setWordWrap(True)
        self.core_layout.addWidget(self.horiz_overflow_warning_label)
        self.horiz_overflow_warning_label.setVisible(
            horiz_scroll_bar.isVisible()
        )
        self.horiz_overflow_warning_binding: WidgetShowBinding = (
            WidgetShowBinding(self.horiz_overflow_warning_label, self)
        )
        horiz_scroll_bar.installEventFilter(self.horiz_overflow_warning_binding)

        vert_scroll_bar: QScrollBar | None = (
            self.dir_list_view.verticalScrollBar()
        )
        assert vert_scroll_bar is not None
        self.vert_overflow_warning_label: QLabel = QLabel(
            '<font color="red">WARNING:</font> Too many paths are listed '
            + "above to display all at once. A new file manager window will "
            + "be opened for every listed path. Double-check that this is "
            + "what you want."
        )
        self.vert_overflow_warning_label.setWordWrap(True)
        self.core_layout.addWidget(self.vert_overflow_warning_label)
        self.vert_overflow_warning_label.setVisible(vert_scroll_bar.isVisible())
        self.vert_overflow_warning_binding: WidgetShowBinding = (
            WidgetShowBinding(self.vert_overflow_warning_label, self)
        )
        vert_scroll_bar.installEventFilter(self.vert_overflow_warning_binding)

        self.foot_label: QLabel = QLabel(
            "Would you like to open these directories? A separate file "
            + "manager window will be opened for every listed path."
        )
        self.core_layout.addWidget(self.foot_label)

        self.button_layout: QHBoxLayout = QHBoxLayout()
        self.display_raw_checkbox: QCheckBox = QCheckBox(
            "Display raw path text"
        )
        self.display_raw_checkbox.stateChanged.connect(self.toggle_display_raw)
        self.button_layout.addWidget(self.display_raw_checkbox)

        self.analyze_paths_button: QPushButton = QPushButton("Analyze Paths")
        self.analyze_paths_button.clicked.connect(self.analyze_paths)
        self.analyze_paths_button.setVisible(False)
        self.button_layout.addWidget(self.analyze_paths_button)
        self.button_layout.addStretch()

        self.open_button: QPushButton = QPushButton("Open")
        self.open_button.clicked.connect(self.open_dir_list)
        self.cancel_button: QPushButton = QPushButton("Cancel")
        self.cancel_button.clicked.connect(self.exit_app)
        self.button_layout.addWidget(self.open_button)
        self.button_layout.addWidget(self.cancel_button)
        self.core_layout.addLayout(self.button_layout)

        self.setLayout(self.core_layout)
        self.setWindowTitle("Open Directories")

        for dir_path in self.dir_list:
            if any(
                (not 0x20 <= ord(path_char) <= 0x7E)
                for path_char in str(dir_path)
            ):
                self.unsafe_text_label.setVisible(True)
                self.analyze_paths_button.setVisible(True)

        self.resize(690, 500)

        self.display_sanitized_dir_paths()
        self.show()

    def display_sanitized_dir_paths(self) -> None:
        """
        Sanitizes the paths in the dir list from all unsafe characters,
        replaces spaces with middle dot characters, displays them to the user,
        and displays a note about text sanitization.
        """

        sanitized_string: str = stdisplay(
            "\n".join([str(x) for x in self.dir_list]), sgr=-1
        )
        self.dir_list_view.setPlainText(
            sanitized_string.replace(" ", "\N{MIDDLE DOT}")
        )
        self.sanitized_text_label.setVisible(True)

    def display_unsanitized_dir_paths(self) -> None:
        """
        Displays the paths in the dir list unsanitized, and hides the text
        sanitization note.
        """

        self.dir_list_view.setPlainText(
            "\n".join([str(x) for x in self.dir_list])
        )
        self.sanitized_text_label.setVisible(False)

    def toggle_display_raw(self, new_state: int) -> None:
        """
        Turns text sanitization on or off.
        """

        if new_state == 0:
            self.display_sanitized_dir_paths()
        else:
            self.display_unsanitized_dir_paths()

    def analyze_paths(self) -> None:
        """
        Opens a new window where the user can analyze potentially unsafe paths
        before opening them with PCManFM-Qt.
        """

        apw: AnalyzePathsWindow = AnalyzePathsWindow(self.dir_list)
        apw.exec()

    # pylint: disable=too-many-branches
    def open_dir_list(self) -> None:
        """
        Opens all specified directories using the default file manager.
        """

        try:
            default_fm_desktop_file: str = subprocess.run(
                ["xdg-mime", "query", "default", "inode/directory"],
                check=True,
                capture_output=True,
                encoding="utf-8",
            ).stdout.strip()
        except Exception:
            print(
                "ERROR: Unable to get default file manager!",
                file=sys.stderr,
            )
            sys.exit(1)

        ## Expect a plain desktop filename, not a path.
        if (
            default_fm_desktop_file == ""
            or "/" in default_fm_desktop_file
            or not default_fm_desktop_file.endswith(".desktop")
        ):
            print(
                "ERROR: Invalid default file manager desktop file name!",
                file=sys.stderr,
            )
            sys.exit(1)

        search_dir_list: list[str] = []

        ## "" rather than None is intentional here, as
        ## os.environ["XDG_DATA_HOME"] may be "", not None.
        xdg_data_home: str = ""
        try:
            xdg_data_home = os.environ["XDG_DATA_HOME"]
        except KeyError:
            pass
        if xdg_data_home == "":
            home_env_var = ""
            try:
                home_env_var = os.environ["HOME"]
            except KeyError:
                pass
            if home_env_var == "":
                print(
                    "ERROR: Both the XDG_DATA_HOME and HOME environment "
                    + "variables are either undefined or empty!",
                    file=sys.stderr,
                )
                sys.exit(1)
            xdg_data_home = home_env_var + "/.local/share"
        if not xdg_data_home.startswith("/"):
            print(
                "ERROR: XDG_DATA_HOME is not an absolute path!", file=sys.stderr
            )
            sys.exit(1)
        search_dir_list.append(xdg_data_home)

        ## Again, using "" rather than None is intentional here.
        xdg_data_dirs: str = ""
        try:
            xdg_data_dirs = os.environ["XDG_DATA_DIRS"]
        except Exception:
            pass
        xdg_data_dir_list = [p for p in xdg_data_dirs.split(":") if p != ""]
        if len(xdg_data_dir_list) == 0:
            xdg_data_dir_list = ["/usr/local/share", "/usr/share"]
        if not all(x.startswith("/") for x in xdg_data_dir_list):
            print(
                "ERROR: XDG_DATA_DIRS contains non-absolute paths!",
                file=sys.stderr,
            )
            sys.exit(1)
        search_dir_list.extend(xdg_data_dir_list)

        found_desktop_file: bool = False
        for search_dir in search_dir_list:
            default_fm_path: Path = Path(
                search_dir + "/applications/" + default_fm_desktop_file
            )
            if default_fm_path.is_file():
                found_desktop_file = True
                break
        if not found_desktop_file:
            print(
                "ERROR: Cannot find default file manager's desktop file!",
                file=sys.stderr,
            )
            sys.exit(1)

        for target_dir in self.dir_list:
            if target_dir.is_dir():
                subprocess.run(
                    ["gio", "launch", str(default_fm_path), str(target_dir)],
                    check=False,
                )
            else:
                QMessageBox.warning(
                    self,
                    "Open Directories",
                    Qt.convertFromPlainText(  # type: ignore
                        "ERROR: The following path was no longer a dir when "
                        + "checked before opening:\n"
                        + "\n"
                        + f"{stdisplay(str(target_dir), sgr=-1)}\n"
                        + "\n"
                        + "This may be the result of an attempted attack. No "
                        + "further directories will be opened."
                    ),
                )
                sys.exit(1)

        sys.exit(0)

    @staticmethod
    def exit_app() -> None:
        """
        Exits the application.
        """

        sys.exit(0)


# pylint: disable=unused-argument
def signal_handler(sig: int, frame: FrameType | None) -> None:
    """
    Handles SIGINT and SIGTERM.
    """

    print("Received SIGINT or SIGTERM, exiting.", file=sys.stderr)
    sys.exit(128 + sig)


def get_path_list_from_uris(
    handler_mode: str, uri_list: list[str]
) -> list[Path]:
    """
    Converts a list of URIs into a list of paths, discarding any invalid URIs
    that are encountered in the process.
    """

    path_set: set[Path] = set()

    for uri in uri_list:
        ## Do not permit Unicode or control characters in URIs. While the
        ## files pointed to by URIs may contain Unicode in their filenames,
        ## Unicode characters beyond 7-bit ASCII are forbidden in URIs. The
        ## org.freedesktop.FileManager1 spec says that we should expect URI
        ## strings here, not IRI strings (which can contain Unicode), so this
        ## is safe.
        if any((not 0x20 <= ord(uri_char) <= 0x7E) for uri_char in uri):
            print(
                "WARNING: Path URI contains Unicode or control characters, "
                + f"skipping. Sanitized URI: '{stdisplay(uri, sgr=-1)}'",
                file=sys.stderr,
            )
            continue

        uri_components: urllib.parse.SplitResult = urllib.parse.urlsplit(uri)

        ## Sanity-check the URI (must be a local file URI, must not have a
        ## query or fragment component)
        if (
            uri_components.scheme != "file"
            or uri_components.netloc not in ("", "localhost")
            or uri_components.query != ""
            or uri_components.fragment != ""
        ):
            print(
                f"WARNING: Path URI '{uri}' does not look like a local file "
                + "URI, skipping.",
                file=sys.stderr,
            )
            continue

        path_str: str = urllib.parse.unquote(uri_components.path)

        ## Do not permit newlines in the path. It is unlikely that these will
        ## be used in practice, and they can easily be used to confuse the
        ## user.
        if "\n" in path_str:
            print(
                "WARNING: Decoded path string contains newlines, skipping. "
                + f"Path: {stdisplay(path_str, sgr=-1)}",
                file=sys.stderr,
            )
            continue

        ## Do not permit NUL characters in the path.
        if "\x00" in path_str:
            print(
                "WARNING: Decoded path string contains NUL characters, "
                + f"skipping. Path: {stdisplay(path_str, sgr=-1)}",
                file=sys.stderr,
            )
            continue

        ## Ensure path_str is absolute.
        if not path_str.startswith("/"):
            print(
                f"WARNING: Path '{stdisplay(path_str, sgr=-1)}' is not "
                + "absolute, skipping.",
                file=sys.stderr,
            )
            continue

        ## Ensure the path is valid, exists, and is accessible.
        path_item: Path = Path(path_str)
        try:
            path_exists = path_item.exists(follow_symlinks=False)
        except OSError as exc:
            ## Under Python 3.13, Path.exists() can raise on pathological
            ## inputs - in particular ENAMETOOLONG (errno 36) when any single
            ## path component exceeds NAME_MAX. An attacker who can inject a
            ## URI here (D-Bus FileManager1 calls from another desktop app)
            ## could otherwise crash this process by sending one long path.
            ## Treat as "non-existent, skip" - same as the False branch. This
            ## issue does not exist in Python 3.14, so we can remove this
            ## check in Forky.
            print(
                f"WARNING: Path could not be checked (errno "
                f"{exc.errno}: {exc.strerror}), skipping. Sanitized "
                f"path: '{stdisplay(path_str, sgr=-1)}'",
                file=sys.stderr,
            )
            continue
        if not path_exists:
            print(
                f"WARNING: Path '{stdisplay(path_str, sgr=-1)}' does not "
                + "exist, skipping.",
                file=sys.stderr,
            )
            continue

        ## Grab the parent dir if necessary. Note that we aren't able to
        ## actually highlight items or show item properties with the API that
        ## PCManFM-Qt provides us, but we are able to open the parent
        ## directories, which at least gets the user closer to the intended
        ## action.
        if handler_mode in ("--show-items", "--show-item-properties"):
            path_item = path_item.parent

        ## Resolve symlinks and '..' components of the path so the user sees
        ## what they're actually going to open.
        try:
            path_item = path_item.resolve(strict=True)
        except OSError:
            print(
                "WARNING: Could not resolve path "
                + f"'{stdisplay(str(path_item), sgr=-1)}', skipping.",
                file=sys.stderr,
            )
            continue

        ## Only respect directories that we can access the contents of.
        if not path_item.is_dir():
            print(
                f"WARNING: Path '{stdisplay(str(path_item), sgr=-1)}' does "
                + "not point to a directory, skipping.",
                file=sys.stderr,
            )
            continue
        if not os.access(path_item, os.R_OK | os.X_OK):
            print(
                f"WARNING: Path '{stdisplay(str(path_item), sgr=-1)}' cannot "
                + "be accessed by us, skipping.",
                file=sys.stderr,
            )
            continue

        path_set.add(path_item)

    output_list: list[Path] = list(path_set)
    output_list.sort()
    return output_list


def main() -> NoReturn:
    """
    Main function.
    """

    handler_mode: str | None = None
    uri_list: list[str] = []

    for arg_idx, arg in enumerate(sys.argv):
        if arg_idx == 0:
            continue
        if arg in ("--show-folders", "--show-items", "--show-item-properties"):
            if handler_mode is not None:
                print(
                    f"ERROR: {arg} and {handler_mode} are mutually exclusive!",
                    file=sys.stderr,
                )
                sys.exit(1)
            handler_mode = arg
        elif arg == "--":
            uri_list = sys.argv[arg_idx + 1 :]
            break
        else:
            uri_list = sys.argv[arg_idx:]
            break

    if len(uri_list) == 0:
        print("ERROR: No URIs provided!", file=sys.stderr)
        sys.exit(1)
    if handler_mode is None:
        print("ERROR: No handler mode provided!", file=sys.stderr)
        sys.exit(1)

    path_list: list[Path] = get_path_list_from_uris(handler_mode, uri_list)
    if len(path_list) == 0:
        print("ERROR: All provided URIs were invalid!", file=sys.stderr)
        sys.exit(1)

    app: QApplication = QApplication(sys.argv)

    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGTERM, signal_handler)

    timer: QTimer = QTimer()
    timer.timeout.connect(lambda: None)
    timer.start(500)

    ## Keep a reference to the window so it isn't garbage-collected.
    # pylint: disable=unused-variable
    fsw: FmShimWindow = FmShimWindow(path_list)  # noqa: F841
    app.exec_()
    sys.exit(0)


if __name__ == "__main__":
    main()
