#!/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")


def validate_script_name(value: str) -> str:
    cleaned = (value or "").strip()
    if cleaned.endswith(".sieve"):
        cleaned = cleaned[:-6]
    if not cleaned or len(cleaned) > 64:
        raise ValueError("Script name must be between 1 and 64 characters")
    if cleaned.lower() in {"active", "default"}:
        raise ValueError("Reserved script name")
    if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", cleaned):
        raise ValueError("Invalid script name characters")
    if ".." in cleaned or any(c in cleaned for c in ("/", "\\", "\x00")):
        raise ValueError("Invalid script name path characters")
    return cleaned


@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 list_sieve(home: int, domain: str, local: str) -> None:
    import json
    scripts = []
    active_script = None
    try:
        with child_dir(home, domain) as domain_fd, child_dir(domain_fd, local) as mailbox:
            try:
                with child_dir(mailbox, "sieve") as sieve:
                    try:
                        target = os.readlink("active.sieve", dir_fd=sieve)
                        if target.endswith(".sieve"):
                            active_script = target[:-6]
                        else:
                            active_script = target
                    except (FileNotFoundError, OSError):
                        active_script = None

                    for item in sorted(os.listdir(sieve)):
                        if item == "active.sieve" or item.endswith(".svbin") or item == "tmp" or item.startswith("."):
                            continue
                        if item.endswith(".sieve"):
                            try:
                                st = os.stat(item, dir_fd=sieve, follow_symlinks=False)
                                if stat.S_ISREG(st.st_mode):
                                    s_name = item[:-6]
                                    scripts.append({
                                        "name": s_name,
                                        "filename": item,
                                        "size": st.st_size,
                                        "modified_at": int(st.st_mtime),
                                        "is_active": (s_name == active_script),
                                    })
                            except OSError:
                                continue
            except FileNotFoundError:
                pass
    except FileNotFoundError:
        pass
    print(json.dumps({"active_script": active_script, "scripts": scripts}))


def get_sieve(home: int, domain: str, local: str, script_name: str) -> None:
    safe_name = validate_script_name(script_name)
    file_name = f"{safe_name}.sieve"
    with child_dir(home, domain) as domain_fd, child_dir(domain_fd, local) as mailbox, child_dir(mailbox, "sieve") as sieve:
        try:
            st = os.stat(file_name, dir_fd=sieve, follow_symlinks=False)
        except FileNotFoundError:
            raise ValueError(f"Script {safe_name} does not exist")
        if not stat.S_ISREG(st.st_mode):
            raise ValueError(f"Script {safe_name} is not a regular file")
        if st.st_size > MAX_SIEVE_BYTES:
            raise ValueError("Sieve script exceeds 1 MiB")
        fd = os.open(file_name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=sieve)
        try:
            with os.fdopen(fd, "rb") as f:
                content = f.read()
                sys.stdout.buffer.write(content)
        except BaseException:
            pass


def put_sieve(home: int, domain: str, local: str, script_name: str, uid: int, gid: int, activate: bool = False) -> None:
    safe_name = validate_script_name(script_name)
    file_name = f"{safe_name}.sieve"
    svbin_name = f"{safe_name}.svbin"
    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 = f".{safe_name}-" + 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, file_name, src_dir_fd=sieve, dst_dir_fd=sieve)
        finally:
            unlink_optional(sieve, temp)
        unlink_optional(sieve, svbin_name)
        if activate:
            unlink_optional(sieve, "active.svbin")
            link_temp = ".active-" + secrets.token_hex(16)
            try:
                os.symlink(file_name, 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)
            unlink_optional(mailbox, ".dovecot.sieve")


def delete_sieve(home: int, domain: str, local: str, script_name: str) -> None:
    safe_name = validate_script_name(script_name)
    file_name = f"{safe_name}.sieve"
    svbin_name = f"{safe_name}.svbin"
    with child_dir(home, domain) as domain_fd, child_dir(domain_fd, local) as mailbox:
        try:
            with child_dir(mailbox, "sieve") as sieve:
                try:
                    active_target = os.readlink("active.sieve", dir_fd=sieve)
                    if active_target in {file_name, safe_name}:
                        unlink_optional(sieve, "active.sieve")
                        unlink_optional(sieve, "active.svbin")
                except (FileNotFoundError, OSError):
                    pass
                unlink_optional(sieve, file_name)
                unlink_optional(sieve, svbin_name)
        except FileNotFoundError:
            pass
        unlink_optional(mailbox, ".dovecot.sieve")


def activate_sieve(home: int, domain: str, local: str, script_name: str, uid: int, gid: int) -> None:
    safe_name = validate_script_name(script_name)
    file_name = f"{safe_name}.sieve"
    with child_dir(home, domain) as domain_fd, child_dir(domain_fd, local) as mailbox:
        with child_dir(mailbox, "sieve") as sieve:
            try:
                st = os.stat(file_name, dir_fd=sieve, follow_symlinks=False)
                if not stat.S_ISREG(st.st_mode):
                    raise ValueError(f"Script {safe_name} is not a regular file")
            except FileNotFoundError:
                raise ValueError(f"Script {safe_name} does not exist")
            unlink_optional(sieve, "active.svbin")
            link_temp = ".active-" + secrets.token_hex(16)
            try:
                os.symlink(file_name, 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)
        unlink_optional(mailbox, ".dovecot.sieve")


def deactivate_sieve(home: int, domain: str, local: str) -> None:
    try:
        with child_dir(home, domain) as domain_fd, child_dir(domain_fd, local) as mailbox:
            try:
                with child_dir(mailbox, "sieve") as sieve:
                    unlink_optional(sieve, "active.sieve")
                    unlink_optional(sieve, "active.svbin")
            except FileNotFoundError:
                pass
            unlink_optional(mailbox, ".dovecot.sieve")
    except FileNotFoundError:
        pass


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,
        "list-sieve": 2,
        "get-sieve": 3,
        "delete-sieve": 3,
        "activate-sieve": 3,
        "deactivate-sieve": 2,
    }
    if command == "put-sieve":
        if len(args) not in (3, 4):
            raise ValueError("Invalid argument count for put-sieve")
    elif 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])
    if len(args) >= 3 and command in {"get-sieve", "delete-sieve", "activate-sieve", "put-sieve"}:
        validate_script_name(args[2])
    create = command in {"ensure-mail-home", "ensure-maildir", "write-sieve", "put-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)
        elif command == "list-sieve":
            list_sieve(home, args[0], args[1])
            return 0
        elif command == "get-sieve":
            get_sieve(home, args[0], args[1], args[2])
            return 0
        elif command == "delete-sieve":
            delete_sieve(home, args[0], args[1], args[2])
        elif command == "activate-sieve":
            activate_sieve(home, args[0], args[1], args[2], uid, gid)
        elif command == "deactivate-sieve":
            deactivate_sieve(home, args[0], args[1])
        elif command == "put-sieve":
            activate_flag = len(args) > 3 and args[3] in {"1", "true", "yes", "activate"}
            put_sieve(home, args[0], args[1], args[2], uid, gid, activate=activate_flag)
    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)
