"""Defence against tarslip + non-regular-file extraction (SH-01, SH-02).

Two tar-extraction surfaces in VManager handle attacker-controlled
archives:

- :mod:`mgf.vm.interchange.importer` extracts OVA files; OVAs are
  downloaded from third parties (analyst appliances, malware sample
  collections, vendor images).
- :mod:`mgf.vm.operations.backup` restores backup archives; in a
  Phase-16 / forensics workflow these can be archives of compromised
  hosts.

Python 3.12 added ``tar.extract(filter="data")`` — the data filter
rejects absolute / parent-traversal member names, refuses symlinks /
devices / FIFOs, and strips setuid/setgid/sticky bits. VManager
declares ``requires-python = ">=3.11"``, where that kwarg doesn't
exist, so the historic code carried a ``TypeError``-caught fallback
to the dangerous pre-PEP-706 default. This module is the in-process
equivalent of the data filter, applied **before** the extract
machinery sees the member — works on 3.11 and 3.12+ identically.

Scope: the protections VManager needs.

- **Path traversal** (CVE-2007-4559 family). A tar member named
  ``../../etc/passwd`` joined onto the destination directory writes
  *outside* the destination. :func:`reject_unsafe_tar_member`
  validates that ``destination_dir / member.name`` resolves *inside*
  ``destination_dir`` (and same for the link target on hardlink /
  symlink members, if those ever pass — they get rejected wholesale,
  but the check costs nothing).
- **Non-regular file types**: only regular files and directories are
  extracted. Symlinks, hardlinks, character / block devices, and
  FIFOs are refused. VManager's two consumers handle disk images +
  text/JSON only — none of those types is legitimate.

NOT in scope here:

- Mode-bit stripping (setuid/setgid/sticky). Neither VManager
  extraction path runs as root in production; the umask handles
  this. Add if a future caller needs it.
- Compressed-archive bomb checks (gzip ratio caps). Tracked
  separately if it ever bites — typical OVA / backup ratios are
  modest.

This helper is the **only** in-process equivalent of the data filter
in VManager. New tar-extraction code MUST call
:func:`reject_unsafe_tar_member` before any ``tar.extract`` /
``tar.extractfile`` / destination-path computation that involves a
member name.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import tarfile
    from pathlib import Path

__all__ = [
    "TarMemberUnsafeError",
    "reject_unsafe_tar_member",
    "safe_destination",
]


class TarMemberUnsafeError(ValueError):
    """Raised when a tar member would unsafely extract.

    Subclass of :class:`ValueError` so callers that translate to
    their own typed exception (``ImportExportError`` for the OVA
    importer, ``BackupError`` for the restore path) can catch it
    with the standard ``except ValueError`` shape; the subclass
    lets a future log filter pin "this specific class" without
    catching every ``ValueError`` in the codebase.
    """


def safe_destination(base: Path, member_name: str) -> Path:
    """Return ``base / member_name`` after checking it stays inside ``base``.

    Raises :class:`TarMemberUnsafeError` if the resolved path
    escapes ``base`` via an absolute member name or ``..`` segments.
    ``base`` is resolved before the comparison so a symlinked
    destination directory is handled correctly. Returns the
    absolute resolved destination on success.
    """
    base_resolved = base.resolve()
    candidate = (base / member_name).resolve()
    try:
        candidate.relative_to(base_resolved)
    except ValueError as e:
        msg = (
            f"refusing to extract tar member with traversal path "
            f"{member_name!r}: would escape {base_resolved}"
        )
        raise TarMemberUnsafeError(msg) from e
    return candidate


def reject_unsafe_tar_member(
    member: tarfile.TarInfo,
    destination_dir: Path,
) -> None:
    """Validate ``member`` is safe to extract into ``destination_dir``.

    Raises :class:`TarMemberUnsafeError` if any of these hold:

    - ``member`` is a symlink, hardlink, character / block device, or
      FIFO. VManager's tar consumers handle disk images and
      text/JSON only; the other types are not legitimate input.
    - ``destination_dir / member.name`` resolves outside
      ``destination_dir`` (absolute path or ``..`` traversal).
    - The member is a hardlink / symlink and its link target
      escapes ``destination_dir``. (Belt-and-braces — these are
      already rejected by the type check above; the second check
      defends against a future change that allows linked members.)

    Call this **before** any ``tar.extract`` / ``tar.extractfile``
    / destination-path computation. Regular files and directories
    pass through with no side effect.
    """
    # Non-regular files: bail loud + early. Catches symlinks,
    # hardlinks, devices, FIFOs.
    if not (member.isfile() or member.isdir()):
        msg = (
            f"refusing to extract non-regular tar member "
            f"{member.name!r} (type={member.type!r}); only regular "
            f"files and directories are supported"
        )
        raise TarMemberUnsafeError(msg)

    # Path-traversal check on the member name.
    safe_destination(destination_dir, member.name)

    # Belt-and-braces: if a member ever DOES pass the type check
    # above as a link (it won't today — isfile/isdir excludes them
    # — but a future change could add hardlink/symlink support),
    # validate the link target too.
    if member.islnk() or member.issym():  # pragma: no cover — gated above
        safe_destination(destination_dir, member.linkname)
