"""Writing files that hold evidence or credentials, safely. (ABR-017)

Four call sites wrote sensitive material four slightly different ways, and
each got a different subset of this right. One primitive instead, because
the parts that were missing are exactly the parts that are easy to forget:

**Permissions must exist before the content does.** `write_text()` then
`chmod(0o600)` leaves the file world-readable under a typical 022 umask
for the window between the two calls, and that window is when a co-tenant
process reads it. `os.open(..., 0o600)` has no window.

**Writes must not follow symlinks.** Without `O_NOFOLLOW`, a local actor
who controls the path can point `.abrade/runs/<id>.json` at any file the
user can write, and the CLI truncates it. `O_NOFOLLOW` makes that an
error instead.

**Replacement must be atomic.** Writing in place with `O_TRUNC` means a
crash — or a full disk — leaves a truncated file that still parses as
"the run". Crash corruption needs no attacker, which makes it the more
likely of the two failures here. Writing a temp file and `os.replace()`
means a reader sees either the old content or the new, never half.

**fsync before rename**, or the rename can land before the bytes do and a
power loss leaves an empty file where a complete one is expected.

POSIX-only in one respect: `O_NOFOLLOW` does not exist on Windows, and
`os.open` ignores the mode argument there. The CLI's threat model is a
shared CI runner or a developer workstation, both POSIX in practice; on
Windows this degrades to an atomic write without the symlink guard rather
than failing.
"""

from __future__ import annotations

import os
import tempfile
from pathlib import Path

# Owner-only, for anything holding evidence or a credential.
SECRET_FILE_MODE = 0o600
SECRET_DIR_MODE = 0o700

_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)


class UnsafePathError(RuntimeError):
    """The target is a symlink, or its directory is not owner-controlled."""


def secure_dir(path: Path) -> Path:
    """Create (or tighten) a directory to 0700 and return it.

    `mkdir(mode=...)` is masked by the umask and skipped entirely when the
    directory already exists, so the mode is set explicitly either way —
    the pre-existing directory is the common case and the one that would
    otherwise keep whatever permissions it was created with.
    """
    path.mkdir(parents=True, exist_ok=True, mode=SECRET_DIR_MODE)
    try:
        os.chmod(path, SECRET_DIR_MODE)
    except OSError:
        # A directory we cannot chmod (someone else's, or a read-only
        # mount) is not worth aborting a run over — the file write below
        # still creates 0600, and it will fail loudly if the directory is
        # genuinely unusable.
        pass
    return path


def _reject_symlink(path: Path) -> None:
    # lstat rather than exists(): exists() follows the link and reports on
    # the target, which is the thing being defended against.
    try:
        if path.is_symlink():
            raise UnsafePathError(
                f"{path} is a symlink. Refusing to write evidence or "
                f"credentials through one — the target may be a file you did "
                f"not intend to overwrite."
            )
    except OSError:
        return


def write_secret_text(path: Path, content: str) -> Path:
    """Atomically write `content` to `path`, owner-readable only.

    The temp file is created in the destination directory rather than
    /tmp, because `os.replace` is only atomic within a filesystem and
    /tmp is frequently a different one.
    """
    _reject_symlink(path)
    secure_dir(path.parent)

    fd, tmp_name = tempfile.mkstemp(
        dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
    )
    tmp = Path(tmp_name)
    try:
        os.fchmod(fd, SECRET_FILE_MODE)
        with os.fdopen(fd, "w") as handle:
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(tmp, path)
    except BaseException:
        # Never leave a partial temp file holding evidence behind.
        tmp.unlink(missing_ok=True)
        raise
    return path


def create_secret_file_exclusively(path: Path, content: str) -> bool:
    """Write `content` only if `path` does not already exist.

    Returns False when it did. `O_EXCL | O_CREAT` makes the check and the
    creation one operation — an `exists()` test followed by a write is a
    race, and the loser silently overwrites whatever the winner put there.
    """
    secure_dir(path.parent)
    try:
        fd = os.open(
            path,
            os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
            SECRET_FILE_MODE,
        )
    except FileExistsError:
        return False
    with os.fdopen(fd, "w") as handle:
        handle.write(content)
        handle.flush()
        os.fsync(handle.fileno())
    return True


def harden_existing(path: Path) -> None:
    """Tighten a file some other library created.

    For outputs written by code that does not accept a file descriptor —
    fpdf2's `output()` takes a path. The window between its create and
    this chmod is real and unavoidable without reimplementing the writer;
    it is narrower than leaving the file 0644 forever, which is what
    happened before.
    """
    try:
        os.chmod(path, SECRET_FILE_MODE)
    except OSError:
        pass
