#!/usr/bin/python3

# SPDX-FileCopyrightText: Copyright 2025-2026 run0edit authors (https://github.com/HastD/run0edit)
#
# SPDX-License-Identifier: Apache-2.0 OR MIT

"""
run0edit - edit a single file as root.

Please report issues at: https://github.com/HastD/run0edit/issues
"""

import argparse
import dataclasses
import enum
import hashlib
import os
import shutil
import stat
import sys
import tempfile
import textwrap
from collections.abc import Sequence
from pathlib import Path
from typing import Final

__version__: Final[str] = "0.6.1"
INNER_SCRIPT_PATH: Final[str] = "/usr/libexec/run0edit/run0edit_inner.py"
INNER_SCRIPT_B2: Final[str] = "\
7d215d913fbc27ace618e1354a6dadcc8277a3eb77dfa38a243d6a5e76e0e350afd5143a0b6bb34059b089cef19a957340cb2c1af4040c0ea489af31875bfb05"
DEFAULT_CONF_PATH: Final[str] = "/etc/run0edit/editor.conf"

CAPABILITY_BOUNDING_SET: Final[list[str]] = [
    "CAP_CHOWN",
    "CAP_DAC_OVERRIDE",
    "CAP_FOWNER",
    "CAP_LINUX_IMMUTABLE",
    "CAP_SETGID",
    "CAP_SETUID",
    "CAP_SYS_NICE",
    "CAP_SYS_RESOURCE",
]

SYSTEM_CALL_DENY: Final[list[str]] = [
    "@aio",
    "@clock",
    "@keyring",
    "@memlock",
    "@module",
    "@mount",
    "@raw-io",
    "@reboot",
    "@swap",
    "memfd_create",
]

SYSTEMD_SANDBOX_PROPERTIES: Final[list[str]] = [
    f"CapabilityBoundingSet={' '.join(CAPABILITY_BOUNDING_SET)}",
    "DevicePolicy=closed",
    "InaccessiblePaths=/run/dbus /run/user",
    "LockPersonality=yes",
    "MemoryDenyWriteExecute=yes",
    "PrivateDevices=yes",
    "PrivateIPC=yes",
    "PrivateNetwork=yes",
    "ProcSubset=pid",
    "ProtectClock=yes",
    "ProtectControlGroups=yes",
    "ProtectHostname=yes",
    "ProtectKernelLogs=yes",
    "ProtectKernelModules=yes",
    "ProtectKernelTunables=yes",
    "ProtectProc=noaccess",
    "RestrictAddressFamilies=AF_UNIX",
    "RestrictNamespaces=yes",
    "RestrictRealtime=yes",
    "RestrictSUIDSGID=yes",
    "SystemCallArchitectures=native",
    "SystemCallFilter=@system-service",
    f"SystemCallFilter=~{' '.join(SYSTEM_CALL_DENY)}",
    "SystemCallErrorNumber=EPERM",
]


def validate_inner_script() -> bool:
    """Ensure inner script has expected BLAKE2 hash."""
    try:
        with open(INNER_SCRIPT_PATH, "rb") as f:
            file_hash = hashlib.blake2b(f.read())
    except OSError:
        return False
    return file_hash.hexdigest() == INNER_SCRIPT_B2


def readonly_filesystem(path: str) -> bool | None:
    """Determine if the path is on a read-only filesystem."""
    try:
        return bool(os.statvfs(path).f_flag & os.ST_RDONLY)
    except OSError:
        return None


class CommandNotFoundError(Exception):
    """An external command was not found."""


def find_command(command: str) -> str:
    """Search for command using a default path."""
    cmd_path = shutil.which(command, path="/usr/bin:/bin:/usr/sbin:/sbin")
    if cmd_path is None:
        raise CommandNotFoundError(command)
    return cmd_path


def is_valid_executable(path: str) -> bool:
    """Test if path is an absolute path to an executable"""
    is_rx = os.R_OK | os.X_OK
    return os.path.isabs(path) and os.path.isfile(path) and os.access(path, is_rx)


class EditorSelectionError(Exception):
    """Parent class for exceptions involving editor selection."""


class EditorNotFoundError(EditorSelectionError):
    """Could not find a valid editor."""


class UnreadableEditorConfError(EditorSelectionError):
    """Editor conf file exists but is unreadable by the current user."""


class InvalidEditorConfError(EditorSelectionError):
    """Editor path in conf file is invalid."""


class InvalidProvidedEditorError(EditorSelectionError):
    """Provided editor path is invalid."""


def get_editor_path_from_conf(conf_path: str = DEFAULT_CONF_PATH) -> str | None:
    """Get path to editor executable from conf file."""
    try:
        with open(conf_path, encoding="utf8") as f:
            # Get the first non-empty line that doesn't start with "#"
            editor = next((line for line in map(str.strip, f) if not line.startswith("#")), None)
    except PermissionError as e:
        raise UnreadableEditorConfError from e
    except OSError:
        return None
    if not editor:
        return None
    if not is_valid_executable(editor):
        raise InvalidEditorConfError(editor)
    return editor


def get_editor_path_from_env() -> str | None:
    """Get path to editor executable from environment variables."""
    for env_var in ("VISUAL", "EDITOR"):
        editor = os.environ.get(env_var)
        if editor is None:
            continue
        editor_path = shutil.which(editor)
        if editor_path is not None:
            return editor_path

    return None


def get_fallback_editor_path(fallbacks: Sequence[str] | None = None) -> str | None:
    """Get fallback editor path."""
    if fallbacks is None:
        fallbacks = ("nano", "vi")
    for fallback in fallbacks:
        try:
            return find_command(fallback)
        except CommandNotFoundError:
            pass
    return None


def get_editor_path(provided_editor: str | None = None) -> str:
    """Get the editor path from a provided path, conf file, env var, or fallback."""
    if provided_editor is not None:
        editor_path = shutil.which(provided_editor)
        if editor_path is None:
            raise InvalidProvidedEditorError(provided_editor)
        return os.path.realpath(provided_editor)
    editor = get_editor_path_from_conf()
    if editor is not None:
        return editor
    editor = get_editor_path_from_env()
    if editor is not None:
        return editor
    editor = get_fallback_editor_path()
    if editor is not None:
        return editor
    raise EditorNotFoundError


def handle_editor_selection(provided_editor: str | None = None) -> str:
    """Get editor path, handling errors by printing error messages and re-raising."""
    try:
        return get_editor_path(provided_editor=provided_editor)
    except EditorNotFoundError:
        print_err(f"""
            Editor not found. Please install either nano or vi, or write the path to
            the text editor of your choice to {DEFAULT_CONF_PATH}
        """)
        raise
    except UnreadableEditorConfError:
        conf_dir = os.path.dirname(DEFAULT_CONF_PATH)
        print_err(
            f"""
            Configuration file exists but is unreadable. Please fix the permissions on
            {DEFAULT_CONF_PATH} to make the file readable by all users. To do so, run the
            following command as root:
                chmod 0755 {conf_dir} && chmod 0644 {DEFAULT_CONF_PATH}
            You may also use the --editor option to specify the editor to use for a single
            run of run0edit.
            """,
            wrap=False,
        )
        raise
    except InvalidEditorConfError:
        print_err(f"""
            Configuration file has an invalid editor. Please edit {DEFAULT_CONF_PATH}
            to contain an absolute path to the executable that you want run0edit to use
            as a text editor. You may also use the --editor option to specify the
            editor to use for a single run of run0edit.
        """)
        raise
    except InvalidProvidedEditorError:
        print_err("--editor must be an absolute path to an executable file")
        raise


class PathExists(enum.Enum):
    """Possibilities for whether a path exists."""

    YES = enum.auto()
    NO = enum.auto()
    MAYBE = enum.auto()

    @classmethod
    def from_bool(cls, cond: bool) -> "PathExists":
        """Convert bool to PathExists"""
        return cls.YES if cond else cls.NO


def check_directory_existence(path: str) -> PathExists:
    """Check whether the directory containing the path exists."""
    real_path = Path(path).resolve()
    partial = Path("/")
    # Walk the directory tree from the filesystem root to the target directory
    for part in real_path.parts[1:-1]:
        try:
            if part not in os.listdir(partial):
                # Next directory doesn't exist
                return PathExists.NO
        except NotADirectoryError:
            return PathExists.NO
        except OSError:
            # Current directory exists but we don't have permission to list its contents
            return PathExists.MAYBE
        partial = partial / part
    try:
        parent_mode = os.stat(real_path.parent).st_mode
    except OSError:
        # Parent exists but unable to determine if it's a directory
        return PathExists.MAYBE
    # If parent is not a directory then path is invalid, otherwise directory exists.
    return PathExists.from_bool(stat.S_ISDIR(parent_mode))


class TempFile:
    """A temporary file."""

    directory: str
    path: str

    def __init__(self, filename: str):
        """Create a temporary file with a random suffix appended to the given filename."""
        self.directory = tempfile.mkdtemp(prefix="run0edit-")
        name = os.path.basename(filename)
        self.path = f"{self.directory}/{name:.64}"
        Path(self.path).touch(mode=0o600, exist_ok=False)

    def remove(self, *, only_if_empty: bool = False) -> None:
        """Delete the temporary file"""
        if only_if_empty and os.path.getsize(self.path) > 0:
            return
        os.remove(self.path)
        try:
            os.rmdir(self.directory)
        except OSError:
            print_err(f"Warning: failed to remove temporary directory {self.directory}")


@dataclasses.dataclass
class Run0Arguments:
    """Arguments to be passed to run0."""

    description: str
    systemd_properties: list[str]
    command: str
    command_args: list[str]
    setenv: dict[str, str] = dataclasses.field(default_factory=dict)
    extra_options: list[str] = dataclasses.field(default_factory=list)
    _run0_cmd: str = dataclasses.field(default_factory=lambda: find_command("run0"))

    def argument_list(self) -> list[str]:
        """Build the argument list that can be executed."""
        args = [self._run0_cmd, f"--description={self.description}"]
        args += [f"--property={prop}" for prop in self.systemd_properties]
        for key, value in self.setenv.items():
            args.append(f"--setenv={key}={value}")
        args += self.extra_options
        args += ["--", self.command, *self.command_args]
        return args


def build_run0_arguments(
    path: str,
    temp_path: str,
    editor: str,
    *,
    bgcolor: str | None = None,
    debug: bool = False,
    no_prompt: bool = False,
) -> Run0Arguments:
    """Construct the arguments to be passed to run0."""
    python_cmd = find_command("python3")
    extra_options = []
    python_args = [INNER_SCRIPT_PATH, path, temp_path, editor]
    if bgcolor is not None:
        extra_options.append(f"--background={bgcolor}")
    setenv = {}
    if debug:
        setenv["RUN0EDIT_DEBUG"] = "1"
    if no_prompt:
        setenv["RUN0EDIT_NO_PROMPT"] = "1"
    return Run0Arguments(
        description=f'run0edit "{path}"',
        systemd_properties=SYSTEMD_SANDBOX_PROPERTIES,
        command=python_cmd,
        command_args=python_args,
        extra_options=extra_options,
        setenv=setenv,
    )


def print_err(message: str, *, wrap: bool = True) -> None:
    """Print error message to stderr with text wrapping."""
    text = "run0edit: " + textwrap.dedent(message.strip("\n"))
    if wrap:
        text = textwrap.fill(text, width=80)
    print(text, file=sys.stderr)


class InvalidPathError(Exception):
    """The provided path is not suitable for use with run0edit."""

    @property
    def reason(self) -> str:
        """Reason why the path is invalid."""
        return str(self.args[0] if self.args else "invalid path")


def validate_path(path: str) -> None:
    """Raise an InvalidPathError if path is invalid and we should return early."""
    if os.path.isdir(path):
        raise InvalidPathError(f"{path} is a directory.")
    if os.path.isfile(path) and os.access(path, os.R_OK | os.W_OK):
        raise InvalidPathError(f"{path} is writable by the current user; run0edit is unnecessary.")
    directory = os.path.dirname(path)
    if check_directory_existence(path) == PathExists.NO:
        raise InvalidPathError(f"No such directory {directory}")
    readonly = readonly_filesystem(path)
    if readonly is None:
        readonly = readonly_filesystem(directory)
    if readonly:
        raise InvalidPathError(f"{path} is on a read-only filesystem.")


def run(
    path: str,
    editor: str,
    *,
    bgcolor: str | None = None,
    debug: bool = False,
    no_prompt: bool = False,
) -> int:
    """Main program to run for a given file."""
    path = os.path.realpath(path)
    try:
        validate_path(path)
    except InvalidPathError as e:
        print_err(e.reason)
        return 1
    temp_file = TempFile(path)
    run0_args = build_run0_arguments(
        path, temp_file.path, editor, bgcolor=bgcolor, debug=debug, no_prompt=no_prompt
    )
    argv = run0_args.argument_list()
    try:
        return os.execv(argv[0], argv)
    except OSError:
        print_err("Warning: execution of run0 failed")
        return 1
    finally:
        temp_file.remove()


def ansi_color(color: str) -> str:
    """
    Return string unmodified if formatted like an ANSI color code, otherwise raise ValueError.
    """
    if color and not all(part.isascii() and part.isdigit() for part in color.split(";")):
        raise ValueError
    return color


def parse_arguments() -> argparse.Namespace:
    """Parse command-line arguments using argparse."""
    description = "run0edit allows a permitted user to edit a file as root."
    epilog = f"""\
    The default choice of text editor may be configured at {DEFAULT_CONF_PATH},
    or by setting the VISUAL or EDITOR environment variables.
    """
    parser = argparse.ArgumentParser(prog="run0edit", description=description, epilog=epilog)
    parser.add_argument("-v", "--version", action="version", version=f"run0edit {__version__}")
    parser.add_argument("--editor", help="absolute path to text editor")
    parser.add_argument(
        "--background",
        type=ansi_color,
        metavar="COLOR",
        help="set ANSI color for background (empty for none)",
    )
    parser.add_argument("--no-prompt", action="store_true", help="skip confirmation prompts")
    parser.add_argument("--debug", action="store_true", help=argparse.SUPPRESS)
    parser.add_argument("paths", nargs="+", metavar="FILE", help="path to the file to be edited")
    return parser.parse_args()


class UsageError(Exception):
    """Incorrect usage of run0edit command."""


def catch_usage_mistake(paths: list[str], *, prompt: bool = True) -> None:
    """
    Check if the user has likely made a usage mistake by passing an executable as the
    first argument.
    """
    if not prompt or len(paths) <= 1:
        return
    arg = paths[0]
    if os.sep in arg or shutil.which(arg) is None:
        return
    print_err(f"""
        Warning: `{arg}` looks like an executable command. If you intended to run {arg}
        as a text editor, use the --editor option or (to make it the default) write its
        absolute path to {DEFAULT_CONF_PATH}.
    """)
    response = input(f"\nDo you really want to edit the file ./{arg}? [y/N] ")
    if not response.casefold().startswith("y"):
        raise UsageError


def main() -> int:
    """Main function. Return value becomes the exit code."""
    args = parse_arguments()
    if not validate_inner_script():
        print_err(f"""
            ERROR: Inner script was not found at {INNER_SCRIPT_PATH} or did not
            have expected BLAKE2 hash!
        """)
        return 1
    try:
        catch_usage_mistake(args.paths, prompt=not args.no_prompt)
    except UsageError:
        return 2
    try:
        editor = handle_editor_selection(provided_editor=args.editor)
    except EditorSelectionError:
        return 1
    exit_code = 0
    for path in args.paths:
        try:
            exit_code = run(
                path, editor, bgcolor=args.background, debug=args.debug, no_prompt=args.no_prompt
            )
        except CommandNotFoundError as e:
            print_err(f"command `{e.args[0]}` not found")
            if args.debug:
                raise e
            return 1
        if exit_code != 0:
            break
    return exit_code


if __name__ == "__main__":  # pragma: no cover
    sys.exit(main())
