# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2016, 2026 InterGenJLU
"""Unit tests for class5_module_sigs — all mocked.

Runs anywhere. No real /proc access, no modinfo, no root.

A future TestClass5PostInstall sibling will exercise the probes against
a real booted InterGenOS target (same pattern as Class 1's post-install
split).
"""

from __future__ import annotations

import contextlib
import io
import tempfile
import textwrap
import unittest
from pathlib import Path
from unittest import mock

from installer.tests import class5_module_sigs as c5


# Representative /proc/keys content with a MOK-derived keyring present.
PROC_KEYS_WITH_MACHINE = textwrap.dedent("""\
    14ca728a I--Q---   219 perm 3f030000  1000  1000 keyring   _ses: 1
    2dd841cf I--Q---     1 perm 1f3f0000  1000 65534 keyring   _uid_ses.1000: 1
    31e0eed8 I--Q---     3 perm 1f3f0000     0     0 keyring   .secondary_trusted_keys: 4
    4088f574 I--Q---     2 perm 1f3f0000     0     0 keyring   .machine: 1
    52abcd01 I------     1 perm 1f010000     0     0 asymmetric  : Build time autogenerated kernel key
""")

# Same content WITHOUT any trust-anchor keyring — represents a kernel
# booted without MOK / secondary trust keys (bad posture for Forge SB).
PROC_KEYS_WITHOUT_TRUST_ANCHOR = textwrap.dedent("""\
    14ca728a I--Q---   219 perm 3f030000  1000  1000 keyring   _ses: 1
    2dd841cf I--Q---     1 perm 1f3f0000  1000 65534 keyring   _uid_ses.1000: 1
""")

# Representative /proc/modules content.
PROC_MODULES_NONEMPTY = textwrap.dedent("""\
    nft_masq 12288 0 - Live 0x0000000000000000
    nf_conntrack 196608 4 xt_conntrack,nf_nat,nft_masq,nf_conntrack_netlink, Live 0x0000000000000000
    vfat 20480 1 - Live 0x0000000000000000
""")

PROC_MODULES_EMPTY = ""

# modinfo output shapes
MODINFO_SIGNED = textwrap.dedent("""\
    filename:       /lib/modules/6.18.10/kernel/net/netfilter/nft_masq.ko.zst
    alias:          nft-expr-masq
    author:         Some Maintainer
    license:        GPL
    srcversion:     ABC
    depends:        nf_nat,nf_conntrack,nf_tables
    retpoline:      Y
    intree:         Y
    name:           nft_masq
    vermagic:       6.18.10-gentoo SMP preempt mod_unload
    sig_id:         PKCS#7
    signer:         Build time autogenerated kernel key
    sig_key:        01:23:45:67:89:ab:cd:ef
    sig_hashalgo:   sha512
""")

MODINFO_UNSIGNED = textwrap.dedent("""\
    filename:       /lib/modules/6.18.10/kernel/some/thing.ko
    author:         Someone
    license:        GPL
    name:           thing
    vermagic:       6.18.10-gentoo SMP preempt mod_unload
""")


def _write_enforce(td: Path, value: str) -> Path:
    path = td / "module_sig_enforce"
    path.write_text(value)
    return path


def _write_keys(td: Path, content: str) -> Path:
    path = td / "keys"
    path.write_text(content)
    return path


def _write_modules(td: Path, content: str) -> Path:
    path = td / "modules"
    path.write_text(content)
    return path


def _mock_modinfo(stdout: str, returncode: int = 0):
    fake = mock.MagicMock()
    fake.return_value.stdout = stdout
    fake.return_value.stderr = ""
    fake.return_value.returncode = returncode
    return fake


# --- Enforce probe --------------------------------------------------------


class TestProbeModuleSigEnforce(unittest.TestCase):
    def test_file_missing(self):
        with tempfile.TemporaryDirectory() as td:
            r = c5.probe_module_sig_enforce(Path(td) / "missing")
        self.assertFalse(r.passed)
        self.assertIn("not present", r.detail)
        self.assertTrue(r.required)

    def test_enforce_enabled(self):
        with tempfile.TemporaryDirectory() as td:
            path = _write_enforce(Path(td), "1")
            r = c5.probe_module_sig_enforce(path)
        self.assertTrue(r.passed)
        self.assertEqual(r.observed, "1")

    def test_enforce_disabled(self):
        with tempfile.TemporaryDirectory() as td:
            path = _write_enforce(Path(td), "0")
            r = c5.probe_module_sig_enforce(path)
        self.assertFalse(r.passed)
        self.assertEqual(r.observed, "0")
        self.assertIn("OFF", r.detail)

    def test_enforce_garbage(self):
        with tempfile.TemporaryDirectory() as td:
            path = _write_enforce(Path(td), "maybe\n")
            r = c5.probe_module_sig_enforce(path)
        self.assertFalse(r.passed)
        self.assertIn("expected 0 or 1", r.detail)


# --- Keyring probe --------------------------------------------------------


class TestProbeSigningKeyring(unittest.TestCase):
    def test_keyring_present(self):
        with tempfile.TemporaryDirectory() as td:
            path = _write_keys(Path(td), PROC_KEYS_WITH_MACHINE)
            r = c5.probe_signing_keyring(path)
        self.assertTrue(r.passed)
        self.assertIn(".secondary_trusted_keys", r.observed)
        self.assertIn(".machine", r.observed)

    def test_keyring_absent(self):
        with tempfile.TemporaryDirectory() as td:
            path = _write_keys(Path(td), PROC_KEYS_WITHOUT_TRUST_ANCHOR)
            r = c5.probe_signing_keyring(path)
        self.assertFalse(r.passed)
        self.assertIn("no trust-anchor keyring", r.detail)

    def test_file_missing(self):
        with tempfile.TemporaryDirectory() as td:
            r = c5.probe_signing_keyring(Path(td) / "missing")
        self.assertFalse(r.passed)
        self.assertIn("not present", r.detail)


# --- Sampled-module probe -------------------------------------------------


class TestPickFirstLoadedModule(unittest.TestCase):
    def test_picks_first(self):
        with tempfile.TemporaryDirectory() as td:
            path = _write_modules(Path(td), PROC_MODULES_NONEMPTY)
            name = c5.pick_first_loaded_module(path)
        self.assertEqual(name, "nft_masq")

    def test_empty_returns_none(self):
        with tempfile.TemporaryDirectory() as td:
            path = _write_modules(Path(td), PROC_MODULES_EMPTY)
            self.assertIsNone(c5.pick_first_loaded_module(path))

    def test_missing_returns_none(self):
        with tempfile.TemporaryDirectory() as td:
            self.assertIsNone(
                c5.pick_first_loaded_module(Path(td) / "missing")
            )


class TestParseModinfoFields(unittest.TestCase):
    def test_signed_module_fields(self):
        fields = c5._parse_modinfo_fields(MODINFO_SIGNED)
        for key in ("signer", "sig_id", "sig_hashalgo"):
            self.assertIn(key, fields)

    def test_unsigned_module_missing_fields(self):
        fields = c5._parse_modinfo_fields(MODINFO_UNSIGNED)
        self.assertNotIn("signer", fields)
        self.assertNotIn("sig_id", fields)


class TestProbeSampledModuleSigned(unittest.TestCase):
    def test_no_modules_passes_not_required(self):
        r = c5.probe_sampled_module_signed(None)
        self.assertTrue(r.passed)
        self.assertFalse(r.required)
        self.assertIn("no loadable modules", r.detail)

    def test_modinfo_absent_fails(self):
        with mock.patch(
            "installer.tests.class5_module_sigs.shutil.which",
            return_value=None,
        ):
            r = c5.probe_sampled_module_signed("nft_masq")
        self.assertFalse(r.passed)
        self.assertTrue(r.required)
        self.assertIn("not in PATH", r.detail)

    def test_signed_module_passes(self):
        with mock.patch(
            "installer.tests.class5_module_sigs.shutil.which",
            return_value="/usr/bin/modinfo",
        ), mock.patch(
            "installer.tests.class5_module_sigs.subprocess.run",
            _mock_modinfo(MODINFO_SIGNED),
        ):
            r = c5.probe_sampled_module_signed("nft_masq")
        self.assertTrue(r.passed)
        self.assertIn("nft_masq", r.observed)
        self.assertIn("signer=", r.observed)

    def test_unsigned_module_fails(self):
        with mock.patch(
            "installer.tests.class5_module_sigs.shutil.which",
            return_value="/usr/bin/modinfo",
        ), mock.patch(
            "installer.tests.class5_module_sigs.subprocess.run",
            _mock_modinfo(MODINFO_UNSIGNED),
        ):
            r = c5.probe_sampled_module_signed("thing")
        self.assertFalse(r.passed)
        self.assertIn("missing modinfo fields", r.detail)

    def test_modinfo_nonzero_exit_fails(self):
        with mock.patch(
            "installer.tests.class5_module_sigs.shutil.which",
            return_value="/usr/bin/modinfo",
        ), mock.patch(
            "installer.tests.class5_module_sigs.subprocess.run",
            _mock_modinfo("", returncode=1),
        ):
            r = c5.probe_sampled_module_signed("nope")
        self.assertFalse(r.passed)
        self.assertIn("failed", r.detail)


# --- End-to-end run tests --------------------------------------------------


class TestRun(unittest.TestCase):
    def test_all_required_pass(self):
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            enforce = _write_enforce(tdp, "1")
            keys = _write_keys(tdp, PROC_KEYS_WITH_MACHINE)
            modules = _write_modules(tdp, PROC_MODULES_NONEMPTY)
            with mock.patch(
                "installer.tests.class5_module_sigs.shutil.which",
                return_value="/usr/bin/modinfo",
            ), mock.patch(
                "installer.tests.class5_module_sigs.subprocess.run",
                _mock_modinfo(MODINFO_SIGNED),
            ):
                report = c5.run(enforce, keys, modules)
        self.assertTrue(report.all_required_pass())
        self.assertEqual(report.sampled_module, "nft_masq")
        self.assertEqual(len(report.results), 3)

    def test_enforce_off_fails_overall(self):
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            enforce = _write_enforce(tdp, "0")
            keys = _write_keys(tdp, PROC_KEYS_WITH_MACHINE)
            modules = _write_modules(tdp, PROC_MODULES_NONEMPTY)
            with mock.patch(
                "installer.tests.class5_module_sigs.shutil.which",
                return_value="/usr/bin/modinfo",
            ), mock.patch(
                "installer.tests.class5_module_sigs.subprocess.run",
                _mock_modinfo(MODINFO_SIGNED),
            ):
                report = c5.run(enforce, keys, modules)
        self.assertFalse(report.all_required_pass())

    def test_no_trust_anchor_keyring_fails_overall(self):
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            enforce = _write_enforce(tdp, "1")
            keys = _write_keys(tdp, PROC_KEYS_WITHOUT_TRUST_ANCHOR)
            modules = _write_modules(tdp, PROC_MODULES_NONEMPTY)
            with mock.patch(
                "installer.tests.class5_module_sigs.shutil.which",
                return_value="/usr/bin/modinfo",
            ), mock.patch(
                "installer.tests.class5_module_sigs.subprocess.run",
                _mock_modinfo(MODINFO_SIGNED),
            ):
                report = c5.run(enforce, keys, modules)
        self.assertFalse(report.all_required_pass())

    def test_monolithic_kernel_all_required_pass(self):
        """enforce=1 + keyring present + zero loaded modules -> PASS.

        A monolithic kernel is the most-secure posture; Class 5 should
        not mark it a failure.
        """
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            enforce = _write_enforce(tdp, "1")
            keys = _write_keys(tdp, PROC_KEYS_WITH_MACHINE)
            modules = _write_modules(tdp, PROC_MODULES_EMPTY)
            report = c5.run(enforce, keys, modules)
        self.assertTrue(report.all_required_pass())
        self.assertIsNone(report.sampled_module)

    def test_sample_module_override(self):
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            enforce = _write_enforce(tdp, "1")
            keys = _write_keys(tdp, PROC_KEYS_WITH_MACHINE)
            modules = _write_modules(tdp, PROC_MODULES_NONEMPTY)
            with mock.patch(
                "installer.tests.class5_module_sigs.shutil.which",
                return_value="/usr/bin/modinfo",
            ), mock.patch(
                "installer.tests.class5_module_sigs.subprocess.run",
                _mock_modinfo(MODINFO_SIGNED),
            ):
                report = c5.run(enforce, keys, modules,
                                sample_module="vfat")
        self.assertEqual(report.sampled_module, "vfat")
        self.assertTrue(report.all_required_pass())

    def test_json_report_shape(self):
        import json as _json
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            enforce = _write_enforce(tdp, "1")
            keys = _write_keys(tdp, PROC_KEYS_WITH_MACHINE)
            modules = _write_modules(tdp, PROC_MODULES_NONEMPTY)
            with mock.patch(
                "installer.tests.class5_module_sigs.shutil.which",
                return_value="/usr/bin/modinfo",
            ), mock.patch(
                "installer.tests.class5_module_sigs.subprocess.run",
                _mock_modinfo(MODINFO_SIGNED),
            ):
                report = c5.run(enforce, keys, modules)
                d = report.to_dict()
        reloaded = _json.loads(_json.dumps(d))
        self.assertTrue(reloaded["all_required_pass"])
        self.assertEqual(reloaded["sampled_module"], "nft_masq")
        self.assertEqual(len(reloaded["results"]), 3)


# --- CLI smoke -------------------------------------------------------------


class TestCLI(unittest.TestCase):
    """stdout redirected so CLI print() doesn't pollute test runner."""

    def test_cli_good_path_exits_zero(self):
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            _write_enforce(tdp, "1")
            _write_keys(tdp, PROC_KEYS_WITH_MACHINE)
            _write_modules(tdp, PROC_MODULES_NONEMPTY)
            with mock.patch(
                "installer.tests.class5_module_sigs.shutil.which",
                return_value="/usr/bin/modinfo",
            ), mock.patch(
                "installer.tests.class5_module_sigs.subprocess.run",
                _mock_modinfo(MODINFO_SIGNED),
            ), contextlib.redirect_stdout(io.StringIO()):
                rc = c5.main([
                    "--enforce-path", str(tdp / "module_sig_enforce"),
                    "--keys-path", str(tdp / "keys"),
                    "--modules-path", str(tdp / "modules"),
                    "--json",
                ])
        self.assertEqual(rc, 0)

    def test_cli_missing_enforce_exits_nonzero(self):
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            _write_keys(tdp, PROC_KEYS_WITH_MACHINE)
            _write_modules(tdp, PROC_MODULES_EMPTY)
            with contextlib.redirect_stdout(io.StringIO()):
                rc = c5.main([
                    "--enforce-path", str(tdp / "missing"),
                    "--keys-path", str(tdp / "keys"),
                    "--modules-path", str(tdp / "modules"),
                    "--json",
                ])
        self.assertEqual(rc, 1)

    def test_cli_report_only_returns_zero_on_fail(self):
        with tempfile.TemporaryDirectory() as td:
            tdp = Path(td)
            with contextlib.redirect_stdout(io.StringIO()):
                rc = c5.main([
                    "--enforce-path", str(tdp / "missing"),
                    "--keys-path", str(tdp / "missing2"),
                    "--modules-path", str(tdp / "missing3"),
                    "--json", "--report-only",
                ])
        self.assertEqual(rc, 0)


if __name__ == "__main__":
    unittest.main()
