"""Regression tests for path traversal in FramenetCorpusReader (CWE-22 / CWE-59).

``doc()``, ``frame_by_name()`` and ``_lu_file()`` interpolate a caller- or
corpus-supplied name into an XML file path that is then read via
``XMLCorpusView``.  A ``..`` sequence, an absolute path, or a Windows drive/UNC
prefix in that name must be rejected *before* any file is opened, while a
legitimate name must still resolve and read correctly through ``self.abspath()``
(the ``nltk.pathsec`` sandbox).

Paths are built with ``os.path.join`` / ``os.pardir`` so the tests behave the
same on POSIX and Windows.

A separate class of tests below covers a symlink planted inside the corpus
subdirectory itself (``frame/``, ``lu/`` or ``fulltext/``). Its own name
contains no separator or ``..``, so it passes ``_reject_unsafe_path_component``
cleanly; only resolving the path with ``Path.resolve()`` and checking it
against the corpus root (what ``_validate_in_root`` / ``validate_path`` do)
catches this.
"""

import builtins
import os

import pytest

from nltk.corpus.reader.framenet import (
    AttrDict,
    FramenetCorpusReader,
    FramenetError,
    _reject_unsafe_path_component,
)

_FRAME_XML = (
    '<?xml version="1.0" encoding="UTF-8"?>\n'
    '<frame xmlns="http://framenet.icsi.berkeley.edu" ID="42" name="{name}">\n'
    "<definition>A minimal test frame.</definition>\n"
    "</frame>\n"
)


def _make_corpus(tmp_path):
    root = tmp_path / "framenet"
    for d in ("frame", "fulltext", "lu"):
        (root / d).mkdir(parents=True)
    (root / "frameIndex.xml").write_text(
        '<?xml version="1.0"?><frameIndex></frameIndex>'
    )
    (root / "frRelation.xml").write_text(
        '<?xml version="1.0"?><frameRelations></frameRelations>'
    )
    return root


def _record_opens(monkeypatch):
    """Spy on builtin open() so a test can assert nothing outside the corpus
    was ever opened."""
    opened = []
    real_open = builtins.open

    def spy(file, *args, **kwargs):
        opened.append(str(file))
        return real_open(file, *args, **kwargs)

    monkeypatch.setattr(builtins, "open", spy)
    return opened


# --- legitimate flow still works (guards against the abspath() refactor) -------


def test_framenet_frame_reads_legitimate_file(tmp_path):
    """A real in-root frame must still parse after routing through abspath()."""
    root = _make_corpus(tmp_path)
    (root / "frame" / "TestFrame.xml").write_text(_FRAME_XML.format(name="TestFrame"))

    fn = FramenetCorpusReader(str(root), [])
    f = fn.frame("TestFrame")
    assert f.name == "TestFrame"
    assert str(f.ID) == "42"


def test_framenet_allows_normal_frame_name(tmp_path):
    """A normal but absent frame name fails as 'unknown', not 'invalid'."""
    fn = FramenetCorpusReader(str(_make_corpus(tmp_path)), [])
    with pytest.raises(FramenetError) as exc:
        fn.frame("NoSuchFrame")
    assert "Invalid frame name" not in str(exc.value)
    assert "Unknown frame" in str(exc.value)


# --- frame_by_name(): traversal + Windows drive rejection ----------------------


def test_framenet_rejects_traversal_frame_name(tmp_path, monkeypatch):
    """A ../ traversal in the frame name is rejected before any file read."""
    root = _make_corpus(tmp_path)
    secret = tmp_path / "outside"
    secret.mkdir()
    (secret / "pwn.xml").write_text(_FRAME_XML.format(name="pwned"))

    fn = FramenetCorpusReader(str(root), [])
    opened = _record_opens(monkeypatch)
    evil = os.path.join(os.pardir, os.pardir, "outside", "pwn")
    with pytest.raises(FramenetError, match="Invalid frame name"):
        fn.frame(evil)
    assert not any("outside" in p for p in opened), "traversal reached the filesystem"


def test_framenet_rejects_drive_prefixed_frame_name(tmp_path):
    """A Windows drive-qualified name (no separator, no '..') is rejected."""
    fn = FramenetCorpusReader(str(_make_corpus(tmp_path)), [])
    with pytest.raises(FramenetError, match="Invalid frame name"):
        fn.frame("C:evil")


# --- doc(): the fulltext filename comes from the (untrusted) corpus index ------


def test_framenet_doc_rejects_traversal_filename(tmp_path, monkeypatch):
    root = _make_corpus(tmp_path)
    fn = FramenetCorpusReader(str(root), [])
    # Simulate a crafted corpus index whose document filename traverses out.
    evil = os.path.join(os.pardir, os.pardir, "outside", "pwn")
    fn._fulltext_idx = {7: AttrDict({"filename": evil})}

    opened = _record_opens(monkeypatch)
    with pytest.raises(FramenetError, match="Invalid document filename"):
        fn.doc(7)
    assert not any("outside" in p for p in opened), "traversal reached the filesystem"


def test_framenet_doc_rejects_drive_prefixed_filename(tmp_path):
    fn = FramenetCorpusReader(str(_make_corpus(tmp_path)), [])
    fn._fulltext_idx = {7: AttrDict({"filename": "C:evil"})}
    with pytest.raises(FramenetError, match="Invalid document filename"):
        fn.doc(7)


# --- _lu_file(): the LU id comes from corpus data ------------------------------


def test_framenet_lu_file_rejects_traversal_id(tmp_path, monkeypatch):
    root = _make_corpus(tmp_path)
    fn = FramenetCorpusReader(str(root), [])
    lu = AttrDict({"ID": os.path.join(os.pardir, os.pardir, "outside", "SECRET")})

    opened = _record_opens(monkeypatch)
    with pytest.raises(FramenetError, match="Invalid LU id"):
        fn._lu_file(lu)
    assert not any("outside" in p for p in opened), "traversal reached the filesystem"


# --- the validation helper itself ---------------------------------------------


@pytest.mark.parametrize(
    "bad",
    ["../x", "a/b", "a\\b", "..", "C:evil", r"\\host\share\x", "/abs.xml"],
)
def test_reject_unsafe_path_component_blocks(bad):
    with pytest.raises(FramenetError):
        _reject_unsafe_path_component(bad, "frame name")


@pytest.mark.parametrize("ok", ["TestFrame", "Apply_heat", "lu123", "a.b-c"])
def test_reject_unsafe_path_component_allows_normal(ok):
    _reject_unsafe_path_component(ok, "frame name")  # must not raise


# --- symlink escape: name has no separator or ".." but still leaves the root --


def _symlink_or_skip(target, link):
    try:
        os.symlink(target, link)
    except (OSError, NotImplementedError):
        pytest.skip("symlinks not supported in this environment")


def test_framenet_frame_rejects_symlink_escape(tmp_path, monkeypatch):
    """A symlink inside frame/ passes the name guard but must still be blocked."""
    root = _make_corpus(tmp_path)
    outside = tmp_path / "outside"
    outside.mkdir()
    secret = outside / "pwn.xml"
    secret.write_text(_FRAME_XML.format(name="pwned"))

    link = root / "frame" / "evil_link.xml"
    _symlink_or_skip(secret, link)

    fn = FramenetCorpusReader(str(root), [])
    opened = _record_opens(monkeypatch)
    with pytest.raises(ValueError, match="escapes root"):
        fn.frame("evil_link")
    assert not any(
        "outside" in p for p in opened
    ), "symlink escape reached the filesystem"


def test_framenet_lu_file_rejects_symlink_escape(tmp_path, monkeypatch):
    """A symlink inside lu/ passes the id guard but must still be blocked."""
    root = _make_corpus(tmp_path)
    outside = tmp_path / "outside"
    outside.mkdir()
    secret = outside / "pwn.xml"
    secret.write_text(
        '<?xml version="1.0"?><lexUnit ID="1" name="pwned.n" status="Created"/>'
    )

    link = root / "lu" / "luevilLU.xml"
    _symlink_or_skip(secret, link)

    fn = FramenetCorpusReader(str(root), [])
    fn._lu_idx = {"__dummy__": AttrDict({"name": "__dummy__"})}  # skip _buildluindex()
    lu = AttrDict({"ID": "evilLU"})
    opened = _record_opens(monkeypatch)
    with pytest.raises(ValueError, match="escapes root"):
        fn._lu_file(lu)
    assert not any(
        "outside" in p for p in opened
    ), "symlink escape reached the filesystem"


def test_framenet_doc_rejects_symlink_escape(tmp_path, monkeypatch):
    """A symlink inside fulltext/ passes the filename guard but must still be blocked."""
    root = _make_corpus(tmp_path)
    outside = tmp_path / "outside"
    outside.mkdir()
    secret = outside / "pwn.xml"
    secret.write_text('<?xml version="1.0"?><fullTextAnnotation></fullTextAnnotation>')

    link = root / "fulltext" / "evilDoc.xml"
    _symlink_or_skip(secret, link)

    fn = FramenetCorpusReader(str(root), [])
    fn._fulltext_idx = {7: AttrDict({"filename": "evilDoc.xml"})}
    opened = _record_opens(monkeypatch)
    with pytest.raises(ValueError, match="escapes root"):
        fn.doc(7)
    assert not any(
        "outside" in p for p in opened
    ), "symlink escape reached the filesystem"
