#!/usr/bin/env python3
"""
Pre-commit hook to enforce rel="noopener noreferrer" on every
<a target="_blank"> that points at an external (cross-origin) URL.

Without rel="noopener", the opened page can access window.opener and
perform tabnabbing attacks. Without rel="noreferrer", the Referer header
leaks the LDR URL to the destination.

The check is "flag unless proven internal": a new-tab link must carry the
rel unless we can statically prove it stays same-origin. This intentionally
also covers dynamic hrefs — JS ${...} and Jinja {{ ... }} expressions that
are not url_for() — since those are exactly where past regressions lived.
Provably same-origin anchors (href starting with "/", "#", "?", a Jinja
url_for, or a non-HTTP pseudo-scheme such as mailto:) are skipped — there is
no cross-origin tabnabbing risk on those.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

# Match <a ...> opening tags, tolerating multi-line attribute lists: the
# negated class [^>] already spans newlines, so a tag whose attributes wrap
# across lines is captured up to its first ">". IGNORECASE also matches
# <A ...>. Known limitation: a ">" inside a quoted attribute value (e.g.
# title="a > b") truncates the match early; our anchors don't contain one.
ANCHOR_RE = re.compile(r"<a\b([^>]*?)>", re.IGNORECASE)

ATTR_RE = re.compile(
    r"""([A-Za-z_:][-A-Za-z0-9_:.]*)        # attribute name
        \s*=\s*
        (?: "([^"]*)" | '([^']*)' | (\S+) ) # quoted or bare value
    """,
    re.VERBOSE,
)


# Jinja url_for() always resolves to a same-origin path, e.g.
# {{ url_for('x') }} or {{- url_for(...) }}.
URL_FOR_RE = re.compile(r"\{\{-?\s*url_for\b", re.IGNORECASE)

# Non-HTTP pseudo-schemes that are not new-tab navigation / tabnabbing vectors.
SAFE_SCHEMES = ("mailto:", "tel:", "sms:", "javascript:", "data:")


def requires_rel(href: str) -> bool:
    """Return True unless the href is provably same-origin / non-navigational.

    Deliberately "flag unless proven internal": a new-tab link should carry
    rel="noopener noreferrer" unless we can PROVE it stays same-origin. This
    covers dynamic links — JS ${...} and Jinja {{ ... }} expressions that are
    not url_for() — which an "only flag provably-external" check would miss
    (and which is exactly where past regressions lived).

    Provably internal (returns False): empty; a same-origin absolute path
    "/..." (but not protocol-relative "//host"); a "#fragment" or "?query"; a
    Jinja {{ url_for(...) }}; and a non-HTTP pseudo-scheme (mailto:, tel:,
    sms:, javascript:, data:). Everything else — http(s)://, //host, a bare
    host like "example.com", or an unresolved ${...} / {{ ... }} expression —
    returns True so the rel is required.
    """
    h = href.strip()
    if not h:
        return False
    # Same-origin absolute path (but NOT protocol-relative //host).
    if h.startswith("/") and not h.startswith("//"):
        return False
    # Fragment or query against the current document.
    if h.startswith(("#", "?")):
        return False
    # Jinja url_for() always resolves to a same-origin path.
    if URL_FOR_RE.match(h):
        return False
    # Non-HTTP pseudo-schemes are not new-tab navigation.
    if h.lower().startswith(SAFE_SCHEMES):
        return False
    # Anything else (http(s)://, //host, bare host, or an unresolved
    # ${...} / {{ ... }} expression) cannot be proven same-origin — flag it.
    return True


def parse_attrs(attr_blob: str) -> dict[str, str]:
    """Parse anchor attributes into a name -> value dict (lowercased keys)."""
    out: dict[str, str] = {}
    for m in ATTR_RE.finditer(attr_blob):
        name = m.group(1).lower()
        value = m.group(2) or m.group(3) or m.group(4) or ""
        out[name] = value
    return out


def check_file(filepath: Path) -> list[tuple[int, str]]:
    """Return list of (line_number, snippet) violations for filepath."""
    try:
        text = filepath.read_text(encoding="utf-8")
    except (UnicodeDecodeError, OSError):
        return []

    violations: list[tuple[int, str]] = []

    for match in ANCHOR_RE.finditer(text):
        attr_blob = match.group(1)
        attrs = parse_attrs(attr_blob)

        target = attrs.get("target", "").lower()
        if target != "_blank":
            continue

        href = attrs.get("href", "")
        if not requires_rel(href):
            continue

        rel = attrs.get("rel", "").lower()
        rel_tokens = set(rel.split())
        if "noopener" in rel_tokens and "noreferrer" in rel_tokens:
            continue

        # Find the line number of the match start
        line_num = text.count("\n", 0, match.start()) + 1
        snippet = match.group(0).strip().replace("\n", " ")
        if len(snippet) > 140:
            snippet = snippet[:137] + "..."
        violations.append((line_num, snippet))

    return violations


def main() -> int:
    files = [Path(f) for f in sys.argv[1:]]

    skip_parts = {"vendor", "node_modules", ".venv", "venv", "__pycache__"}

    all_violations: list[tuple[Path, list[tuple[int, str]]]] = []
    for filepath in files:
        if any(part in skip_parts for part in filepath.parts):
            continue
        vs = check_file(filepath)
        if vs:
            all_violations.append((filepath, vs))

    if not all_violations:
        return 0

    print('\nExternal <a target="_blank"> missing rel="noopener noreferrer":\n')
    print(
        "Without noopener, the opened page can access window.opener "
        "(tabnabbing).\nWithout noreferrer, the Referer header leaks the "
        "LDR URL to the destination.\n"
    )
    for filepath, vs in all_violations:
        print(f"  {filepath}")
        for line_num, snippet in vs:
            print(f"    line {line_num}: {snippet}")
        print()

    print(
        'Fix: add rel="noopener noreferrer" to each flagged anchor. '
        "Provably same-origin\n"
        "links (href starts with /, #, ?, a Jinja url_for, or a mailto:/tel: "
        "scheme)\nare skipped automatically. For a dynamic href that is "
        'genuinely internal,\nprefer a leading "/" so the check can prove it.'
    )
    return 1


if __name__ == "__main__":
    sys.exit(main())
