#!/usr/bin/env python3
"""Fixed Maildir operations for the sudo wrapper, using pinned directory handles.

MAIL_HOME is writable by the application group. Never resolve a checked pathname
again: every child operation is relative to an open, non-symlink directory.
"""
from __future__ import annotations

from contextlib import contextmanager
import errno
import os
from pathlib import PurePosixPath
import re
import secrets
import shutil
import stat
import sys

DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
SPECIAL_FOLDERS = ("Archive", "Drafts", "Junk", "Sent", "Trash")
MAX_SIEVE_BYTES = 1024 * 1024


def validate_home(raw: str, package: str) -> str:
    if not raw.startswith("/") or "\x00" in raw or any(ord(c) < 32 for c in raw):
        raise ValueError("MAIL_HOME must be an absolute path without control characters")
    if ".." in PurePosixPath(raw).parts:
        raise ValueError("MAIL_HOME must not contain '..'")
    path = os.path.normpath(raw)
    if path.startswith("//"):
        raise ValueError("MAIL_HOME must not start with '//'")
    if len(PurePosixPath(path).parts) < 4:
        raise ValueError("Refusing to manage a top-level MAIL_HOME")
    forbidden = (
        "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/root",
        "/proc", "/sys", "/dev", "/run", "/var/lib", "/var/log", "/var/spool/postfix",
        os.path.normpath(package),
    )
    if any(path == item or path.startswith(item + "/") for item in forbidden):
        raise ValueError("Refusing to manage MAIL_HOME inside a system or package path")
    return path


def validate_component(value: str, *, domain: bool = False) -> None:
    if domain:
        safe = bool(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?", value))
    else:
        safe = 0 < len(value) <= 64 and not value.startswith(".")
    if not safe or ".." in value or any(c in value for c in ("/", "\\", "\x00")) or any(ord(c) < 32 or ord(c) == 127 for c in value):
        raise ValueError("Invalid domain or local part path component")


@contextmanager
def child_dir(parent: int, name: str, *, create: bool = False, mode: int = 0o700):
    if create:
        try:
            os.mkdir(name, mode, dir_fd=parent)
        except FileExistsError:
            pass
    fd = os.open(name, DIR_FLAGS, dir_fd=parent)
    try:
        yield fd
    finally:
        os.close(fd)


def open_home(path: str, *, create: bool) -> int:
    fd = os.open("/", DIR_FLAGS)
    try:
        for part in PurePosixPath(path).parts[1:]:
            if create:
                try:
                    os.mkdir(part, 0o750, dir_fd=fd)
                except FileExistsError:
                    pass
            next_fd = os.open(part, DIR_FLAGS, dir_fd=fd)
            os.close(fd)
            fd = next_fd
        return fd
    except BaseException:
        os.close(fd)
        raise


def set_owner_mode(fd: int, uid: int, gid: int, mode: int) -> None:
    info = os.fstat(fd)
    if stat.S_ISREG(info.st_mode) and info.st_nlink > 1 and info.st_uid == uid and info.st_gid == gid and stat.S_IMODE(info.st_mode) == mode:
        # Dovecot may legitimately hard-link copies between Maildir folders.
        # Such inodes need no mutation; never chmod/chown them through a link.
        return
    if not stat.S_ISDIR(info.st_mode) and (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1):
        raise ValueError("Refusing to change a special file or a multiply linked file")
    if info.st_uid != uid or info.st_gid != gid:
        os.fchown(fd, uid, gid)
    os.fchmod(fd, mode)


def normalize_tree(fd: int, uid: int, gid: int) -> None:
    """Repair an existing tree without following links or opening FIFOs/devices."""
    for name in os.listdir(fd):
        info = os.stat(name, dir_fd=fd, follow_symlinks=False)
        if stat.S_ISLNK(info.st_mode):
            # Dovecot uses an intentional active.sieve symlink; leave link inodes alone.
            continue
        if stat.S_ISDIR(info.st_mode):
            with child_dir(fd, name) as sub:
                normalize_tree(sub, uid, gid)
        elif stat.S_ISREG(info.st_mode):
            item = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=fd)
            try:
                set_owner_mode(item, uid, gid, 0o600)
            finally:
                os.close(item)
        else:
            raise ValueError("Refusing special file in mailbox")
    set_owner_mode(fd, uid, gid, 0o700)


def ensure_maildir(home: int, domain: str, local: str, uid: int, gid: int) -> None:
    with child_dir(home, domain, create=True, mode=0o2770) as domain_fd:
        with child_dir(domain_fd, local, create=True) as mailbox:
            for name in ("cur", "new", "tmp", "sieve"):
                with child_dir(mailbox, name, create=True):
                    pass
            for folder in SPECIAL_FOLDERS:
                with child_dir(mailbox, "." + folder, create=True) as special:
                    for name in ("cur", "new", "tmp"):
                        with child_dir(special, name, create=True):
                            pass
            try:
                subscriptions = os.open("subscriptions", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=mailbox)
            except FileExistsError:
                # Existing subscriptions must be a regular, unlinked file too.
                subscriptions = os.open("subscriptions", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=mailbox)
                try:
                    set_owner_mode(subscriptions, uid, gid, 0o600)
                finally:
                    os.close(subscriptions)
            else:
                with os.fdopen(subscriptions, "wb") as handle:
                    handle.write(("\n".join(SPECIAL_FOLDERS) + "\n").encode())
                    set_owner_mode(handle.fileno(), uid, gid, 0o600)
            normalize_tree(mailbox, uid, gid)
        set_owner_mode(domain_fd, uid, gid, 0o2770)


def remove_entry(parent: int, name: str) -> None:
    try:
        info = os.stat(name, dir_fd=parent, follow_symlinks=False)
    except FileNotFoundError:
        return
    if stat.S_ISDIR(info.st_mode):
        if not shutil.rmtree.avoids_symlink_attacks:
            raise RuntimeError("Python does not support safe recursive deletion")
        shutil.rmtree(name, dir_fd=parent)
    else:
        os.unlink(name, dir_fd=parent)


def unlink_optional(parent: int, name: str) -> None:
    try:
        os.unlink(name, dir_fd=parent)
    except FileNotFoundError:
        pass


def write_sieve(home: int, domain: str, local: str, uid: int, gid: int) -> None:
    payload = sys.stdin.buffer.read(MAX_SIEVE_BYTES + 1)
    if len(payload) > MAX_SIEVE_BYTES:
        raise ValueError("Sieve script exceeds 1 MiB")
    ensure_maildir(home, domain, local, uid, gid)
    with child_dir(home, domain) as domain_fd, child_dir(domain_fd, local) as mailbox, child_dir(mailbox, "sieve") as sieve:
        temp = ".autoresponder-" + secrets.token_hex(16)
        fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=sieve)
        try:
            with os.fdopen(fd, "wb") as handle:
                handle.write(payload)
                handle.flush()
                set_owner_mode(handle.fileno(), uid, gid, 0o600)
                os.fsync(handle.fileno())
            os.replace(temp, "autoresponder.sieve", src_dir_fd=sieve, dst_dir_fd=sieve)
        finally:
            unlink_optional(sieve, temp)
        unlink_optional(mailbox, ".dovecot.sieve")
        link_temp = ".active-" + secrets.token_hex(16)
        try:
            os.symlink("autoresponder.sieve", link_temp, dir_fd=sieve)
            os.chown(link_temp, uid, gid, dir_fd=sieve, follow_symlinks=False)
            os.replace(link_temp, "active.sieve", src_dir_fd=sieve, dst_dir_fd=sieve)
        finally:
            unlink_optional(sieve, link_temp)


def clear_sieve(home: int, domain: str, local: str) -> None:
    try:
        with child_dir(home, domain) as domain_fd, child_dir(domain_fd, local) as mailbox:
            # Open sieve before changing anything; a symlinked sieve is invalid.
            try:
                with child_dir(mailbox, "sieve") as sieve:
                    for name in os.listdir(sieve):
                        if name in {"autoresponder.sieve", "active.sieve"} or name.endswith(".svbin"):
                            unlink_optional(sieve, name)
            except FileNotFoundError:
                pass
            unlink_optional(mailbox, ".dovecot.sieve")
    except FileNotFoundError:
        return


def main(argv: list[str]) -> int:
    if len(argv) < 5:
        raise ValueError("Missing mailbox helper arguments")
    raw_home, uid_text, gid_text, package, command, *args = argv
    home_path = validate_home(raw_home, package)
    uid, gid = int(uid_text), int(gid_text)
    if not 0 <= uid < 2**32 - 1 or not 0 <= gid < 2**32 - 1:
        raise ValueError("Invalid vmail uid/gid")
    expected = {"ensure-mail-home": 0, "ensure-maildir": 2, "delete-maildir": 2, "delete-domain": 1, "write-sieve": 2, "clear-sieve": 2}
    if command not in expected or len(args) != expected[command]:
        raise ValueError("Invalid command or argument count")
    if args:
        validate_component(args[0], domain=True)
    if len(args) == 2:
        validate_component(args[1])
    create = command in {"ensure-mail-home", "ensure-maildir", "write-sieve"}
    try:
        home = open_home(home_path, create=create)
    except FileNotFoundError:
        if create:
            raise
        print("ok " + home_path)
        return 0
    try:
        if create:
            set_owner_mode(home, uid, gid, 0o2770)
        if command == "ensure-maildir":
            ensure_maildir(home, *args, uid, gid)
        elif command == "delete-domain":
            remove_entry(home, args[0])
        elif command == "delete-maildir":
            try:
                with child_dir(home, args[0]) as domain:
                    remove_entry(domain, args[1])
                try:
                    os.rmdir(args[0], dir_fd=home)
                except OSError as exc:
                    if exc.errno not in {errno.ENOTEMPTY, errno.ENOENT}:
                        raise
            except FileNotFoundError:
                pass
        elif command == "write-sieve":
            write_sieve(home, *args, uid, gid)
        elif command == "clear-sieve":
            clear_sieve(home, *args)
    finally:
        os.close(home)
    print("ok " + os.path.join(home_path, *args))
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main(sys.argv[1:]))
    except (OSError, ValueError, RuntimeError) as exc:
        print(f"Mailbox filesystem operation refused: {exc}", file=sys.stderr)
        raise SystemExit(2)
