"""Security-focused unit tests for parser/auth edge cases.

NOTE FOR MAINTAINERS:
Add new tests in `test/test_security_attack_surface_unit_part2.py`.
This file is intentionally capped to keep size manageable.
"""

import io
import os
import tempfile
import unittest
from contextlib import redirect_stderr
from unittest.mock import patch

from lshell.checkconfig import CheckConfig
from lshell.shellcmd import ShellCmd
from lshell import sec
from lshell import utils

TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../"
CONFIG = f"{TOPDIR}/test/testfiles/test.conf"


class DummyLog:
    """Lightweight logger used by parser execution tests."""

    def __init__(self):
        self.messages = []

    def warn(self, message):
        """Record warning-level messages."""
        self.messages.append(("warn", message))

    def info(self, message):
        """Record info-level messages."""
        self.messages.append(("info", message))

    def critical(self, message):
        """Record critical-level messages."""
        self.messages.append(("critical", message))


class DummyShellContext:
    """Minimal shell context consumed by utils.cmd_parse_execute."""

    def __init__(self, conf):
        self.conf = conf
        self.log = DummyLog()

    def do_help(self, _arg):
        """Emulate a successful help command."""
        return 0

    def do_exit(self, _arg=None):
        """Emulate a successful exit command."""
        return 0


class TestAttackSurface(unittest.TestCase):
    """Tests aimed at command-injection/bypass style edge cases."""

    args = [f"--config={CONFIG}", "--quiet=1"]

    def test_split_command_sequence_does_not_split_escaped_pipe(self):
        """Treat escaped pipes as literal text in a command token."""
        line = r"echo a\|b | wc -c"
        self.assertEqual(
            utils.split_command_sequence(line), [r"echo a\|b", "|", "wc -c"]
        )

    def test_split_command_sequence_allows_trailing_background(self):
        """Allow a trailing background operator as a separate token."""
        self.assertEqual(utils.split_command_sequence("sleep 1 &"), ["sleep 1", "&"])

    def test_split_command_sequence_rejects_operator_smuggling(self):
        """Reject malformed operator chains that should fail closed."""
        self.assertIsNone(utils.split_command_sequence("echo ok ||| echo pwn"))

    def test_check_forbidden_chars_allows_double_ampersand_when_single_forbidden(self):
        """Permit && when only single & is forbidden by policy."""
        conf = CheckConfig(self.args + ["--forbidden=['&']", "--strict=0"]).returnconf()
        ret, _conf = sec.check_forbidden_chars("echo ok && echo still_ok", conf)
        self.assertEqual(ret, 0)

    def test_check_forbidden_chars_blocks_single_ampersand(self):
        """Block single & when forbidden characters include ampersand."""
        conf = CheckConfig(self.args + ["--forbidden=['&']", "--strict=0"]).returnconf()
        starting_counter = conf["warning_counter"]
        ret, _conf = sec.check_forbidden_chars("echo ok &", conf)
        self.assertEqual(ret, 1)
        self.assertEqual(_conf["warning_counter"], starting_counter - 1)

    def test_check_path_forbidden_decrements_counter_even_when_not_strict(self):
        """Forbidden path should consume warning counter regardless of strict mode."""
        conf = CheckConfig(
            self.args + ["--path=['/home', '/var']", "--strict=0"]
        ).returnconf()
        starting_counter = conf["warning_counter"]
        ret, _conf = sec.check_path("cd /tmp", conf, strict=0)
        self.assertEqual(ret, 1)
        self.assertEqual(_conf["warning_counter"], starting_counter - 1)

    def test_check_path_uses_canonical_prefix_not_substring_regex(self):
        """Do not allow sibling paths that only share a string prefix."""
        with tempfile.TemporaryDirectory(prefix="lshell-path-prefix-", dir="/tmp") as tmpdir:
            allowed_dir = os.path.join(tmpdir, "allow")
            sibling_dir = os.path.join(tmpdir, "allow-evil")
            os.makedirs(allowed_dir, exist_ok=True)
            os.makedirs(sibling_dir, exist_ok=True)

            conf = CheckConfig(
                self.args + [f"--path=['{allowed_dir}']", "--strict=0"]
            ).returnconf()
            ret, _conf = sec.check_path(f"ls {sibling_dir}", conf, strict=0)
            self.assertEqual(ret, 1)

    def test_check_path_validates_all_glob_matches(self):
        """Validate every wildcard expansion result, not only the first match."""
        with tempfile.TemporaryDirectory(prefix="lshell-path-glob-", dir="/tmp") as tmpdir:
            allowed_dir = os.path.join(tmpdir, "a_allowed")
            blocked_dir = os.path.join(tmpdir, "z_blocked")
            os.makedirs(allowed_dir, exist_ok=True)
            os.makedirs(blocked_dir, exist_ok=True)

            conf = CheckConfig(
                self.args + [f"--path=['{allowed_dir}']", "--strict=0"]
            ).returnconf()
            ret, _conf = sec.check_path(f"ls {tmpdir}/*", conf, strict=0)
            self.assertEqual(ret, 1)

    def test_check_path_allow_root_minus_var_allows_cd_root(self):
        """Root allow-list entry '/' must stay valid when minus-paths are present."""
        conf = CheckConfig(
            self.args + ["--path=['/'] - ['/var']", "--strict=0"]
        ).returnconf()
        ret, _conf = sec.check_path("cd /", conf, strict=0)
        self.assertEqual(ret, 0)

    def test_check_path_root_minus_var_denies_relative_cd_var(self):
        """Relative cd target should still be validated against path ACL."""
        conf = CheckConfig(
            self.args + ["--path=['/'] - ['/var']", "--strict=0"]
        ).returnconf()
        previous_cwd = os.getcwd()
        try:
            os.chdir("/")
            ret, _conf = sec.check_path("cd var", conf, strict=0)
            self.assertEqual(ret, 1)
        finally:
            os.chdir(previous_cwd)

    def test_check_path_more_specific_allow_overrides_broader_deny(self):
        """Specific allow path should win over broader deny prefix."""
        conf = CheckConfig(
            self.args
            + ["--path=['/'] - ['/var','/var/lib'] + ['/var/log']", "--strict=0"]
        ).returnconf()
        ret, _conf = sec.check_path("cd /var/log", conf, strict=0)
        self.assertEqual(ret, 0)

    def test_check_path_does_not_treat_command_word_as_path(self):
        """Command names like 'cd' must not be reported as denied filesystem paths."""
        conf = CheckConfig(
            self.args + ["--path=['/tmp']", "--strict=0", "--quiet=0"]
        ).returnconf()

        with patch("lshell.sec.warn_count", return_value=(1, conf)) as mock_warn:
            ret, _conf = sec.check_path("cd /var/log", conf, strict=0)

        self.assertEqual(ret, 1)
        self.assertTrue(mock_warn.called)
        warned_path = mock_warn.call_args.args[1]
        self.assertNotEqual(warned_path, os.path.realpath("cd"))
        self.assertNotEqual(os.path.basename(warned_path.rstrip("/")), "cd")

    def test_check_path_completion_still_validates_single_path_token(self):
        """Single-token path checks (completion/policy mode) must keep working."""
        conf = CheckConfig(
            self.args + ["--path=['/home', '/var']", "--strict=0"]
        ).returnconf()
        ret, _conf = sec.check_path("/tmp", conf, completion=1, strict=0)
        self.assertEqual(ret, 1)

    def test_checkconfig_rejects_allowed_shell_escape_all(self):
        """Reject allowed_shell_escape=all to avoid bypassing noexec globally."""
        with self.assertRaises(SystemExit):
            CheckConfig(self.args + ["--allowed_shell_escape='all'"]).returnconf()

    def test_cmdloop_executes_login_script_with_bash_script_invocation(self):
        """Run configured login_script at shell startup, including 'bash <script>' syntax."""
        conf = CheckConfig(self.args + ["--strict=0"]).returnconf()
        conf["login_script"] = "bash test/testfiles/login_script.sh"
        shell = ShellCmd(
            conf,
            args=[],
            stdin=io.StringIO(),
            stdout=io.StringIO(),
            stderr=io.StringIO(),
        )
        shell.cmdqueue = ["exit"]

        with patch(
            "lshell.shellcmd.utils.cmd_parse_execute", return_value=0
        ) as mock_exec:
            with patch("lshell.shellcmd.sys.exit", side_effect=SystemExit):
                with self.assertRaises(SystemExit):
                    shell.cmdloop()

        mock_exec.assert_called_once_with(
            "bash test/testfiles/login_script.sh", shell_context=shell
        )

    def test_check_secure_assignment_prefix_keeps_exact_command_matching(self):
        """Enforce command allowlist even when prefixed by variable assignments."""
        conf = CheckConfig(
            self.args + ["--allowed=['echo ok']", "--forbidden=[]", "--strict=0"]
        ).returnconf()
        self.assertEqual(sec.check_secure("A=1 echo ok", conf)[0], 0)
        self.assertEqual(sec.check_secure("A=1 echo nope", conf)[0], 1)

    def test_check_secure_unknown_command_does_not_decrement_counter_when_not_strict(
        self,
    ):
        """Treat disallowed commands as unknown syntax when strict mode is disabled."""
        conf = CheckConfig(
            self.args
            + ["--allowed=['echo']", "--forbidden=[]", "--strict=0", "--quiet=0"]
        ).returnconf()
        starting_counter = conf["warning_counter"]
        stderr = io.StringIO()
        with redirect_stderr(stderr):
            ret, conf = sec.check_secure("no_such_allowed_command", conf, strict=0)
        self.assertEqual(ret, 1)
        self.assertEqual(conf["warning_counter"], starting_counter)
        self.assertIn(
            "lshell: unknown syntax: no_such_allowed_command", stderr.getvalue()
        )

    def test_check_secure_unknown_command_decrements_counter_when_strict(self):
        """Count disallowed commands as forbidden actions when strict mode is enabled."""
        conf = CheckConfig(
            self.args
            + ["--allowed=['echo']", "--forbidden=[]", "--strict=1", "--quiet=0"]
        ).returnconf()
        starting_counter = conf["warning_counter"]
        stderr = io.StringIO()
        with redirect_stderr(stderr):
            ret, conf = sec.check_secure("no_such_allowed_command", conf, strict=1)
        self.assertEqual(ret, 1)
        self.assertEqual(conf["warning_counter"], starting_counter - 1)
        self.assertIn("lshell: warning:", stderr.getvalue())

    def test_check_secure_assignment_prefix_with_sudo_still_checks_subcommand(self):
        """Validate sudo subcommands after assignment prefixes."""
        conf = CheckConfig(
            self.args
            + [
                "--allowed=['sudo']",
                "--sudo_commands=['ls']",
                "--forbidden=[]",
                "--strict=0",
            ]
        ).returnconf()
        self.assertEqual(sec.check_secure("A=1 sudo ls", conf)[0], 0)
        self.assertEqual(sec.check_secure("A=1 sudo cat /etc/passwd", conf)[0], 1)

    def test_check_secure_sudo_u_with_assignment_prefix_is_authorized(self):
        """Allow authorized sudo -u command with an assignment prefix."""
        conf = CheckConfig(
            self.args
            + [
                "--allowed=['sudo']",
                "--sudo_commands=['ls']",
                "--forbidden=[]",
                "--strict=0",
            ]
        ).returnconf()
        self.assertEqual(sec.check_secure("A=1 sudo -u root ls", conf)[0], 0)

    def test_check_secure_sudo_u_missing_command_fails_closed(self):
        """Reject sudo -u when no subcommand is provided."""
        conf = CheckConfig(
            self.args
            + [
                "--allowed=['sudo']",
                "--sudo_commands=['ls']",
                "--forbidden=[]",
                "--strict=0",
            ]
        ).returnconf()
        self.assertEqual(sec.check_secure("sudo -u root", conf)[0], 1)

    def test_check_secure_rejects_control_char_in_line(self):
        """Reject control characters embedded in command input."""
        conf = CheckConfig(self.args + ["--strict=0"]).returnconf()
        # Literal vertical-tab control char.
        self.assertEqual(sec.check_secure("echo\x0btest", conf)[0], 1)

    def test_check_secure_rejects_disallowed_command_substitution(self):
        """Reject substitution constructs that invoke disallowed commands."""
        conf = CheckConfig(
            self.args
            + ["--allowed=['echo']", "--forbidden=[';','&','|','>','<']", "--strict=0"]
        ).returnconf()
        self.assertEqual(sec.check_secure("echo $(cat /etc/passwd)", conf)[0], 1)
        self.assertEqual(sec.check_secure("echo `cat /etc/passwd`", conf)[0], 1)

    def test_cmd_parse_execute_rejects_unbalanced_syntax(self):
        """Return an error when parser input has unbalanced syntax."""
        conf = CheckConfig(self.args + ["--strict=0"]).returnconf()
        shell = DummyShellContext(conf)
        starting_counter = conf["warning_counter"]
        stderr = io.StringIO()
        with redirect_stderr(stderr):
            ret = utils.cmd_parse_execute('echo "oops', shell_context=shell)
        self.assertEqual(ret, 1)
        self.assertEqual(conf["warning_counter"], starting_counter)
        self.assertIn("lshell: unknown syntax:", stderr.getvalue())

    def test_cmd_parse_execute_unbalanced_syntax_decrements_counter_in_strict_mode(
        self,
    ):
        """In strict mode, unknown syntax should consume warning counter."""
        conf = CheckConfig(self.args + ["--strict=1", "--quiet=0"]).returnconf()
        shell = DummyShellContext(conf)
        starting_counter = conf["warning_counter"]
        stderr = io.StringIO()
        with redirect_stderr(stderr):
            ret = utils.cmd_parse_execute('echo "oops', shell_context=shell)
        self.assertEqual(ret, 126)
        self.assertEqual(conf["warning_counter"], starting_counter - 1)
        self.assertIn("lshell: warning:", stderr.getvalue())

    def test_cmd_parse_execute_malformed_operator_decrements_counter_in_strict_mode(
        self,
    ):
        """In strict mode, malformed operators should consume warning counter."""
        conf = CheckConfig(self.args + ["--strict=1", "--quiet=0"]).returnconf()
        shell = DummyShellContext(conf)
        starting_counter = conf["warning_counter"]
        stderr = io.StringIO()
        with redirect_stderr(stderr):
            ret = utils.cmd_parse_execute("echo ok ||| echo pwn", shell_context=shell)
        self.assertEqual(ret, 126)
        self.assertEqual(conf["warning_counter"], starting_counter - 1)
        self.assertIn("lshell: warning:", stderr.getvalue())

    @patch("lshell.utils.sec.check_forbidden_chars")
    @patch("lshell.utils.sec.check_secure")
    @patch("lshell.utils.sec.check_path")
    @patch("lshell.utils.exec_cmd")
    def test_cmd_parse_execute_short_circuit_skips_failed_and_branch(
        self, mock_exec, mock_path, mock_secure, mock_forbidden
    ):
        """Skip right-hand commands after failed && and execute || recovery."""
        conf = CheckConfig(
            self.args
            + [
                "--allowed=['false','skip1','skip2','echo']",
                "--forbidden=[]",
                "--strict=0",
            ]
        ).returnconf()
        shell = DummyShellContext(conf)

        mock_forbidden.side_effect = lambda line, conf, strict=None: (0, conf)
        mock_secure.side_effect = lambda line, conf, strict=None: (0, conf)
        mock_path.side_effect = lambda line, conf, strict=None: (0, conf)

        def exec_side_effect(command, background=False, extra_env=None, **_kwargs):
            if command == "false":
                return 1
            if command == "echo recovered":
                return 0
            return 99

        mock_exec.side_effect = exec_side_effect

        ret = utils.cmd_parse_execute(
            "false && skip1 | skip2 || echo recovered", shell_context=shell
        )

        self.assertEqual(ret, 0)
        executed = [call.args[0] for call in mock_exec.call_args_list]
        self.assertEqual(executed, ["false", "echo recovered"])

    @patch("lshell.utils.sec.check_forbidden_chars")
    @patch("lshell.utils.sec.check_secure")
    @patch("lshell.utils.sec.check_path")
    @patch("lshell.utils.exec_cmd")
    def test_cmd_parse_execute_assignment_only_updates_parent_env_without_exec(
        self, mock_exec, mock_path, mock_secure, mock_forbidden
    ):
        """Apply assignment-only input to parent env without spawning a command."""
        conf = CheckConfig(self.args + ["--forbidden=[]", "--strict=0"]).returnconf()
        shell = DummyShellContext(conf)

        mock_forbidden.side_effect = lambda line, conf, strict=None: (0, conf)
        mock_secure.side_effect = lambda line, conf, strict=None: (0, conf)
        mock_path.side_effect = lambda line, conf, strict=None: (0, conf)

        original = os.environ.get("LSHELL_ATTACK_SURFACE")
        try:
            ret = utils.cmd_parse_execute(
                "LSHELL_ATTACK_SURFACE=present", shell_context=shell
            )
            self.assertEqual(ret, 0)
            self.assertEqual(os.environ.get("LSHELL_ATTACK_SURFACE"), "present")
            mock_exec.assert_not_called()
        finally:
            if original is None:
                os.environ.pop("LSHELL_ATTACK_SURFACE", None)
            else:
                os.environ["LSHELL_ATTACK_SURFACE"] = original

    @patch("lshell.utils.sec.check_forbidden_chars")
    @patch("lshell.utils.sec.check_secure")
    @patch("lshell.utils.sec.check_path")
    @patch("lshell.utils.exec_cmd")
    def test_cmd_parse_execute_allowed_shell_escape_skips_ld_preload(
        self, mock_exec, mock_path, mock_secure, mock_forbidden
    ):
        """allowed_shell_escape commands should execute without LD_PRELOAD wrapping."""
        conf = CheckConfig(
            self.args
            + [
                "--allowed=['sudo']",
                "--sudo_commands=['ls']",
                "--allowed_shell_escape=['sudo']",
                "--forbidden=[]",
                "--strict=0",
            ]
        ).returnconf()
        conf["path_noexec"] = "/tmp/fake_noexec.so"
        shell = DummyShellContext(conf)

        mock_forbidden.side_effect = lambda line, conf, strict=None: (0, conf)
        mock_secure.side_effect = lambda line, conf, strict=None: (0, conf)
        mock_path.side_effect = lambda line, conf, strict=None: (0, conf)
        mock_exec.return_value = 0

        ret = utils.cmd_parse_execute("sudo ls", shell_context=shell)

        self.assertEqual(ret, 0)
        self.assertEqual(mock_exec.call_count, 1)
        self.assertEqual(mock_exec.call_args.args[0], "sudo ls")
        self.assertIsNone(
            mock_exec.call_args.kwargs.get("extra_env"),
            msg="allowed_shell_escape should bypass LD_PRELOAD injection",
        )

    @patch("lshell.utils.signal.getsignal", return_value=None)
    @patch("lshell.utils.signal.signal")
    @patch("lshell.utils.subprocess.Popen")
    def test_exec_cmd_sudo_not_wrapped_in_bash_c(
        self, mock_popen, _mock_signal, _mock_getsignal
    ):
        """sudo commands should be executed directly, not through 'bash -c'."""

        class FakeProc:
            """Minimal subprocess fake for exec_cmd foreground path."""

            def __init__(self):
                self.returncode = 0
                self.pid = 12345
                self.args = ["sudo", "ls"]
                self.lshell_cmd = ""

            def communicate(self):
                """Simulate foreground process I/O completion."""
                return None

            def poll(self):
                """Simulate an already-finished subprocess."""
                return 0

        mock_popen.return_value = FakeProc()

        ret = utils.exec_cmd("sudo ls")

        self.assertEqual(ret, 0)
        popen_args = mock_popen.call_args.args[0]
        self.assertEqual(popen_args[0], "sudo")
        self.assertNotEqual(popen_args[:2], ["bash", "-c"])

    @patch("lshell.utils.signal.getsignal", return_value=None)
    @patch("lshell.utils.signal.signal")
    @patch("lshell.utils.subprocess.Popen")
    def test_exec_cmd_sudo_keeps_foreground_terminal(
        self, mock_popen, _mock_signal, _mock_getsignal
    ):
        """sudo should not be detached from the controlling terminal."""

        class FakeProc:
            """Minimal subprocess fake for exec_cmd foreground path."""

            def __init__(self):
                self.returncode = 0
                self.pid = 12345
                self.args = ["sudo", "ls"]
                self.lshell_cmd = ""

            def communicate(self):
                """Simulate foreground process I/O completion."""
                return None

            def poll(self):
                """Simulate an already-finished subprocess."""
                return 0

        mock_popen.return_value = FakeProc()

        ret = utils.exec_cmd("sudo ls")

        self.assertEqual(ret, 0)
        popen_kwargs = mock_popen.call_args.kwargs
        self.assertNotIn(
            "preexec_fn",
            popen_kwargs,
            msg="sudo should run in the current foreground session to keep tty access",
        )

    @patch("lshell.utils.signal.getsignal", return_value=None)
    @patch("lshell.utils.signal.signal")
    @patch("lshell.utils.subprocess.Popen")
    def test_exec_cmd_su_not_wrapped_in_bash_c(
        self, mock_popen, _mock_signal, _mock_getsignal
    ):
        """su commands should be executed directly, not through 'bash -c'."""

        class FakeProc:
            """Minimal subprocess fake for exec_cmd foreground path."""

            def __init__(self):
                self.returncode = 0
                self.pid = 12345
                self.args = ["su", "-"]
                self.lshell_cmd = ""

            def communicate(self):
                """Simulate foreground process I/O completion."""
                return None

            def poll(self):
                """Simulate an already-finished subprocess."""
                return 0

        mock_popen.return_value = FakeProc()

        ret = utils.exec_cmd("su -")

        self.assertEqual(ret, 0)
        popen_args = mock_popen.call_args.args[0]
        self.assertEqual(popen_args[0], "su")
        self.assertNotEqual(popen_args[:2], ["bash", "-c"])

    @patch("lshell.utils.signal.getsignal", return_value=None)
    @patch("lshell.utils.signal.signal")
    @patch("lshell.utils.subprocess.Popen")
    def test_exec_cmd_su_keeps_foreground_terminal(
        self, mock_popen, _mock_signal, _mock_getsignal
    ):
        """su should not be detached from the controlling terminal."""

        class FakeProc:
            """Minimal subprocess fake for exec_cmd foreground path."""

            def __init__(self):
                self.returncode = 0
                self.pid = 12345
                self.args = ["su", "-"]
                self.lshell_cmd = ""

            def communicate(self):
                """Simulate foreground process I/O completion."""
                return None

            def poll(self):
                """Simulate an already-finished subprocess."""
                return 0

        mock_popen.return_value = FakeProc()

        ret = utils.exec_cmd("su -")

        self.assertEqual(ret, 0)
        popen_kwargs = mock_popen.call_args.kwargs
        self.assertNotIn(
            "preexec_fn",
            popen_kwargs,
            msg="su should run in the current foreground session to keep tty access",
        )
