NumericalOS

tests/test_verifier_agreement.py

back to source

"""The build-time verifier and the boot-time verifier must decide alike.

numos/state.py::verify runs on a build machine; boot/bootstrap.sh's
numos_verify_state runs as PID 1 on the target. Any input the two disagree
about is a hole: either the target refuses a file the build certified
(a brick), or the target accepts a file the build would have rejected
(an unauthenticated field inside the one artifact verification exists to
protect). Each case below is run through BOTH verifiers and their verdicts
compared, rather than asserting on one side alone.
"""

import os
import subprocess
import tempfile
import unittest

from numos import seed
from numos.state import render, verify, StateError

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BOOTSTRAP = os.path.join(ROOT, "boot", "bootstrap.sh").replace("\\", "/")


def good_state():
    return render({"version": 1, "arch_targets": seed.ARCH_TARGETS,
                   "boot_phases": seed.BOOT_PHASES, "units": seed.INFRA_UNITS,
                   "health": []})


def c_hash(text):
    return [ln for ln in text.split("\n") if ln.startswith("C ")][0][2:]


def write_temp(text):
    handle = tempfile.NamedTemporaryFile("w", suffix=".numos", delete=False, newline="\n")
    handle.write(text)
    handle.close()
    return handle.name.replace("\\", "/")


def shell_accepts(text):
    """Run boot/bootstrap.sh's verifier. Returns (accepted, stderr)."""
    path = write_temp(text)
    try:
        full = 'NUMOS_SOURCE_ONLY=1 . "%s"; numos_verify_state "%s"' % (BOOTSTRAP, path)
        env = dict(os.environ)
        env["NUMOS_SOURCE_ONLY"] = "1"
        env["NUMOS_LIB"] = os.path.dirname(BOOTSTRAP) + "/lib"
        proc = subprocess.run(["bash", "-c", full], capture_output=True, text=True, env=env)
        return proc.returncode == 0, proc.stderr.strip()
    finally:
        os.unlink(path)


def python_accepts(text):
    """Run numos.state.verify. Returns (accepted, message)."""
    try:
        verify(text)
        return True, ""
    except StateError as exc:
        return False, str(exc)


class VerifierAgreement(unittest.TestCase):
    def assertBothAccept(self, text):
        py_ok, py_msg = python_accepts(text)
        sh_ok, sh_err = shell_accepts(text)
        self.assertTrue(py_ok, "numos.state.verify rejected: %s" % py_msg)
        self.assertTrue(sh_ok, "numos_verify_state rejected: %s" % sh_err)

    def assertBothReject(self, text):
        py_ok, _ = python_accepts(text)
        sh_ok, sh_err = shell_accepts(text)
        self.assertFalse(py_ok, "numos.state.verify accepted it")
        self.assertFalse(sh_ok, "numos_verify_state accepted it")
        self.assertIn("numos: HALT:", sh_err)


class TestGoodState(VerifierAgreement):
    def test_a_rendered_state_is_accepted_by_both(self):
        self.assertBothAccept(good_state())


class TestCRecordTail(VerifierAgreement):
    """`C <valid-hash> JUNK` -- the shell used to take `cut -f2` and accept."""

    def test_trailing_junk_after_the_hash_is_rejected_by_both(self):
        text = good_state()
        bad = text.replace("C " + c_hash(text), "C " + c_hash(text) + " JUNK")
        self.assertIn(" JUNK", bad)
        self.assertBothReject(bad)

    def test_trailing_space_after_the_hash_is_rejected_by_both(self):
        text = good_state()
        self.assertBothReject(text.replace("C " + c_hash(text), "C " + c_hash(text) + " "))

    def test_truncated_hash_is_rejected_by_both(self):
        text = good_state()
        self.assertBothReject(text.replace("C " + c_hash(text), "C " + c_hash(text)[:32]))

    def test_uppercase_hash_is_rejected_by_both(self):
        text = good_state()
        self.assertBothReject(text.replace("C " + c_hash(text), "C " + c_hash(text).upper()))


class TestBlankLines(VerifierAgreement):
    """Python used to drop blank lines before hashing; grep never did."""

    def test_extra_trailing_newline_is_rejected_by_both(self):
        self.assertBothReject(good_state() + "\n")

    def test_blank_line_in_the_body_is_rejected_by_both(self):
        text = good_state()
        lines = text.split("\n")
        lines.insert(3, "")
        self.assertBothReject("\n".join(lines))

    def test_the_single_terminating_newline_is_not_a_blank_line(self):
        # The complement of the two cases above: a normally terminated file
        # must still verify, so the fix cannot be "reject anything with a
        # newline near the end".
        text = good_state()
        self.assertTrue(text.endswith("\n"))
        self.assertFalse(text.endswith("\n\n"))
        self.assertBothAccept(text)


class TestTamperedBody(VerifierAgreement):
    def test_edited_record_is_rejected_by_both(self):
        self.assertBothReject(good_state().replace("busybox-riscv64", "busybox-EVIL"))


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