elogtool: add pytest for elogtool

This CL adds a python test for elogtool.
It tests the basic functionality of elogtool: list, clear and add.
A future CL will include more complex tests.

BUG=b:172210863
TEST=pytest elogtool_test.py

Change-Id: If1241ad070d1c690c84f5ca61c0487ba27c2a287
Signed-off-by: Ricardo Quesada <ricardoq@google.com>
Reviewed-on: https://review.coreboot.org/c/coreboot/+/57869
Tested-by: build bot (Jenkins) <no-reply@coreboot.org>
Reviewed-by: Jack Rosenthal <jrosenth@chromium.org>
Reviewed-by: Patrick Georgi <pgeorgi@google.com>
diff --git a/util/cbfstool/tests/README.md b/util/cbfstool/tests/README.md
new file mode 100644
index 0000000..8ba6974
--- /dev/null
+++ b/util/cbfstool/tests/README.md
@@ -0,0 +1,26 @@
+# CBFSTool tests
+
+To run the tests do `pytest name_of_the_file.py`. E.g:
+
+```shell
+$ pytest elogtool_test.py
+```
+
+## Dependencies
+
+### Pytest
+
+Requires `pytest`. To install it do:
+
+```shell
+$ pip install --user pytest
+```
+
+### Binaries
+
+Make sure that you have compiled the cbfstool binaries before running the test. e.g:
+
+```shell
+$ cd $COREBOOT_SRC/util/cbfstool
+$ make
+```
diff --git a/util/cbfstool/tests/conftest.py b/util/cbfstool/tests/conftest.py
new file mode 100644
index 0000000..4e77924
--- /dev/null
+++ b/util/cbfstool/tests/conftest.py
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pathlib
+
+
+def pytest_addoption(parser):
+    here = pathlib.Path(__file__).parent
+    parser.addoption(
+        "--elogtool-path",
+        type=pathlib.Path,
+        default=(here / ".." / "elogtool").resolve(),
+    )
diff --git a/util/cbfstool/tests/elogtool_test.py b/util/cbfstool/tests/elogtool_test.py
new file mode 100644
index 0000000..a798a4c
--- /dev/null
+++ b/util/cbfstool/tests/elogtool_test.py
@@ -0,0 +1,150 @@
+#!/usr/bin/python3
+# SPDX-License-Identifier: BSD-3-Clause
+
+import os
+import pytest
+import struct
+import subprocess
+from datetime import datetime
+from datetime import timedelta
+
+# Defined in include/commonlib/bsd/elog.h
+ELOG_TYPE_SYSTEM_BOOT = 0x17
+ELOG_TYPE_EOL = 0xff
+ELOG_EVENT_HEADER_SIZE = 8
+ELOG_EVENT_CHECKSUM_SIZE = 1
+
+
+def convert_to_event(s: str) -> dict:
+    fields = s.split("|")
+    assert len(fields) == 3 or len(fields) == 4
+
+    return {
+        "index": int(fields[0]),
+        "timestamp": datetime.strptime(fields[1].strip(), "%Y-%m-%d %H:%M:%S"),
+        "desc": fields[2].strip(),
+        "data": fields[3].strip() if len(fields) == 4 else None,
+    }
+
+
+def compare_event(expected: dict, got: dict) -> None:
+    # Ignore the keys that might be in "got", but not in "expected".
+    # In particular "timestamp" might not want to be tested.
+    for key in expected:
+        assert key in got.keys()
+        assert expected[key] == got[key]
+
+
+@pytest.fixture(scope="session")
+def elogtool_path(request):
+    exe = request.config.option.elogtool_path
+    assert os.path.exists(exe)
+    return exe
+
+
+@pytest.fixture(scope="function")
+def elogfile(tmp_path):
+    header_size = 8
+    tail_size = 512 - header_size
+
+    # Elog header:
+    #  Magic (4 bytes) = "ELOG"
+    #  Version (1 byte) = 1
+    #  Size (1 byte) = 8
+    #  Reserved (2 bytes) = 0xffff
+    header = struct.pack("4sBBH", bytes("ELOG", "utf-8"), 1, 8, 0xffff)
+
+    # Fill the tail with EOL events.
+    tail = bytes([ELOG_TYPE_EOL] * tail_size)
+    buf = header + tail
+
+    buf_path = tmp_path / "elog_empty.bin"
+    with buf_path.open("wb") as fd:
+        fd.write(buf)
+        fd.flush()
+        return str(buf_path)
+    assert False
+
+
+def elog_list(elogtool_path: str, path: str) -> list:
+    output = subprocess.run([elogtool_path, 'list', '-f', path],
+                            capture_output=True, check=True)
+    log = output.stdout.decode("utf-8").strip()
+
+    lines = log.splitlines()
+    lines = [convert_to_event(s.strip()) for s in lines]
+    return lines
+
+
+def elog_clear(elogtool_path: str, path: str) -> None:
+    subprocess.run([elogtool_path, 'clear', '-f', path], check=True)
+
+
+def elog_add(elogtool_path: str, path: str, typ: int, data: bytearray) -> None:
+    subprocess.run([elogtool_path, 'add', '-f', path,
+                   hex(typ), data.hex()], check=True)
+
+
+def test_list_empty(elogtool_path, elogfile):
+    events = elog_list(elogtool_path, elogfile)
+    assert len(events) == 0
+
+
+def test_clear_empty(elogtool_path, elogfile):
+    elog_clear(elogtool_path, elogfile)
+    events = elog_list(elogtool_path, elogfile)
+
+    # Must have one event, the "Log area cleared" event.
+    assert len(events) == 1
+
+    expected = {"index": 0,
+                "desc": "Log area cleared",
+                # "0", since it was an empty elog buffer. No bytes were cleared.
+                "data": "0"}
+    compare_event(expected, events[0])
+
+
+def test_clear_not_empty(elogtool_path, elogfile):
+    tot_events = 10
+    data_size = 4
+    event_size = ELOG_EVENT_HEADER_SIZE + data_size + ELOG_EVENT_CHECKSUM_SIZE
+    written_bytes = tot_events * event_size
+
+    for i in range(tot_events):
+        # Adding boot_count for completeness. But it is ignored in this test.
+        boot_count = i.to_bytes(data_size, "little")
+        elog_add(elogtool_path, elogfile, ELOG_TYPE_SYSTEM_BOOT, boot_count)
+    elog_clear(elogtool_path, elogfile)
+    events = elog_list(elogtool_path, elogfile)
+
+    # Must have one event, the "Log area cleared" event.
+    assert len(events) == 1
+
+    expected = {"index": 0,
+                "desc": "Log area cleared",
+                "data": str(written_bytes)
+                }
+    compare_event(expected, events[0])
+
+
+def test_add_single_event(elogtool_path, elogfile):
+    # "before - one second" is needed because datetime.now() fills the
+    # microsecond variable. But eventlog doesn't use it, and has it hardcoded to
+    # zero.
+    before = datetime.now() - timedelta(seconds=1)
+    boot_count = 128
+    elog_add(elogtool_path, elogfile, ELOG_TYPE_SYSTEM_BOOT,
+             boot_count.to_bytes(4, "little"))
+    after = datetime.now()
+
+    events = elog_list(elogtool_path, elogfile)
+    assert len(events) == 1
+
+    ev = events[0]
+    expected = {"index": 0,
+                "desc": "System boot",
+                "data": str(boot_count)
+                }
+    compare_event(expected, ev)
+
+    assert before < ev["timestamp"] < after