"""Regression: _update_via_zip must reject ZIP members with symlink mode.

A symlink member in a downloaded update ZIP would let an attacker who can
serve / MITM the update mirror plant a symlink that extractall() then
follows, writing arbitrary file content outside the staging directory.
The Linux mode bits live in the upper 16 bits of ``ZipInfo.external_attr``;
we explicitly reject any member whose type bits are S_IFLNK.
"""

import os
import stat
import tempfile
import zipfile
from unittest.mock import patch

import pytest


def _build_zip_with_symlink_member(zip_path: str, link_name: str, target: str) -> None:
    """Write a ZIP containing a single member with S_IFLNK mode bits set."""
    with zipfile.ZipFile(zip_path, "w") as zf:
        info = zipfile.ZipInfo(link_name)
        # Upper 16 bits = Unix mode; mark as symlink (0o120000) + 0o777 perms.
        info.external_attr = (stat.S_IFLNK | 0o777) << 16
        # The "data" of a symlink ZIP member is the link target string.
        zf.writestr(info, target)


def _build_normal_zip(zip_path: str) -> None:
    """Write a regular ZIP with a normal file member (no symlink)."""
    with zipfile.ZipFile(zip_path, "w") as zf:
        zf.writestr("hermes-agent-main/README.md", "ok\n")


def test_update_via_zip_rejects_symlink_member(tmp_path, monkeypatch):
    """A symlink member in the update ZIP must raise before extractall."""
    zip_path = tmp_path / "evil.zip"
    _build_zip_with_symlink_member(
        str(zip_path),
        link_name="hermes-agent-main/evil-link",
        target="/etc/passwd",
    )

    from hermes_cli.main import _update_via_zip

    args = type("Args", (), {})()

    # Patch urlretrieve to "download" our pre-built malicious ZIP into the
    # _update_via_zip tempdir. Capture the tempdir so we can prove no
    # extraction happened.
    captured = {}
    original_mkdtemp = tempfile.mkdtemp

    def capturing_mkdtemp(*args, **kwargs):
        d = original_mkdtemp(*args, **kwargs)
        captured["tmp_dir"] = d
        return d

    def fake_urlretrieve(url, dest):
        # Copy our malicious zip into the destination dest path.
        with open(zip_path, "rb") as src, open(dest, "wb") as dst:
            dst.write(src.read())
        return dest, None

    with patch("tempfile.mkdtemp", side_effect=capturing_mkdtemp), \
         patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve):
        # _update_via_zip catches ValueError, prints the message, and exits 1.
        # That's the contract: a malicious ZIP must fail the update, not
        # silently materialize a symlink.
        with pytest.raises(SystemExit) as exc_info:
            _update_via_zip(args)
        assert exc_info.value.code == 1

    # Belt: confirm extractall never produced the link.
    tmp_dir = captured.get("tmp_dir")
    if tmp_dir:
        evil_path = os.path.join(tmp_dir, "hermes-agent-main", "evil-link")
        assert not os.path.lexists(evil_path), (
            "symlink member should never be materialized"
        )


def test_update_via_zip_accepts_normal_member(tmp_path, monkeypatch, capsys):
    """A ZIP with only regular file members must extract without raising.

    Sanity check that the symlink reject didn't break the happy path.  We
    point ``PROJECT_ROOT`` at an isolated tmp dir so the function's
    ``shutil.copytree(src, dst)`` over PROJECT_ROOT lands in a sandbox, NOT
    the real repo checkout (which previously stomped on README.md whenever
    this test ran, leaving 'ok\\n' there and breaking
    ``test_readme_mentions_powershell_installer`` for everyone else).
    """
    zip_path = tmp_path / "normal.zip"
    _build_normal_zip(str(zip_path))

    # Sandbox PROJECT_ROOT so the file-copy phase can't escape the test's
    # tmp tree. The function only reads PROJECT_ROOT to derive dst paths.
    fake_root = tmp_path / "install_dir"
    fake_root.mkdir()

    from hermes_cli import main as hermes_main

    monkeypatch.setattr(hermes_main, "PROJECT_ROOT", fake_root)

    args = type("Args", (), {})()

    def fake_urlretrieve(url, dest):
        with open(zip_path, "rb") as src, open(dest, "wb") as dst:
            dst.write(src.read())
        return dest, None

    # Stub the post-extract pip/uv reinstall so we don't actually run pip.
    # The function may sys.exit(1) when those commands fail; that's fine —
    # we only care that ZIP validation + extraction completed without
    # raising "symlink member".
    with patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve), \
         patch("subprocess.run") as fake_run, \
         patch("subprocess.check_call"):
        fake_run.return_value = type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
        try:
            hermes_main._update_via_zip(args)
        except SystemExit:
            pass

    captured = capsys.readouterr()
    assert "symlink member" not in captured.out
    assert "symlink member" not in captured.err
    # The fake README from the ZIP should have landed in our sandbox root,
    # confirming the extraction + copy phases ran past the validation gate.
    assert (fake_root / "README.md").exists()
    assert (fake_root / "README.md").read_text() == "ok\n"
