diff --git a/.github/workflows/m33mu.yml b/.github/workflows/m33mu.yml index 6b1d822b..68d84ea1 100644 --- a/.github/workflows/m33mu.yml +++ b/.github/workflows/m33mu.yml @@ -241,13 +241,11 @@ jobs: if grep -Eq '^(\[MEMFAULT\]|\[HARDFLT\]|HardFault|SecureFault)' "$log"; then exit 1 fi - # Both guests raw-write the same UART, so guest1 can splice into the - # middle of a secure/guest0 marker line. Flatten (strip guest1 text, - # rejoin the split line) before matching those markers; match guest1's - # own markers against the raw log. Mirrors run_m33mu_scenario.sh's - # expect_flat. - flat=$(sed 's/freertos_guest1:.*$//' "$log" | tr -d '\r\n') - need() { printf '%s' "$flat" | grep -Fq "$1" || { echo "missing: $1" >&2; exit 1; }; } + # Rejoin guest0 lines interrupted by UART attach banners or complete + # guest1 records. Keep the raw log for guest1 and fault checks. + guest0_log="$RUNNER_TEMP/wolfboot-wolftrust-m33mu-guest0.log" + python3 tests/target/lib/m33mu_console.py "$log" > "$guest0_log" + need() { grep -Fq "$1" "$guest0_log" || { echo "missing: $1" >&2; exit 1; }; } need_raw() { grep -Fq "$1" "$log" || { echo "missing: $1" >&2; exit 1; }; } need "wolfTrust TEE client initialized" need "wolfTrust FF-M mediated crypto dispatch verified" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 32cc1858..e57e2460 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,6 +20,8 @@ jobs: matrix: ${{ steps.s.outputs.matrix }} steps: - uses: actions/checkout@v4 + - name: Test M33MU console marker reconstruction + run: python3 tests/scripts/test_m33mu_console.py - id: s # Single source of truth: the Makefile's UNIT_SUITES. Adding a suite # there makes it a new CI check automatically — no workflow edit. diff --git a/Makefile b/Makefile index 2853fc46..9060f09a 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ include mk/common.mk all: $(ARCH_DEFAULT_GOALS) test: + @python3 tests/scripts/test_m33mu_console.py @$(MAKE) --no-print-directory -C tests/host test C99_CFLAGS := -std=c99 -pedantic-errors -Werror=vla \ diff --git a/tests/scripts/test_m33mu_console.py b/tests/scripts/test_m33mu_console.py new file mode 100644 index 00000000..0c761ae5 --- /dev/null +++ b/tests/scripts/test_m33mu_console.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# test_m33mu_console.py +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of wolfTrust. +# +# wolfTrust is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# wolfTrust is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +import importlib.util +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +HELPER = Path(__file__).resolve().parents[1] / "target/lib/m33mu_console.py" +SPEC = importlib.util.spec_from_file_location("m33mu_console", HELPER) +CONSOLE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CONSOLE) + +PREFIX = (b"[00:00:00.063,000] \x1b[0m guest0_psa: " + b"wolfTrust FF-M oversized-vector call rejecte") +TAIL = b"d st=-135\x1b[0m\r\n" +MARKER = b"wolfTrust FF-M oversized-vector call rejected st=-135" +BANNER = b"[UART] 40004800 attached to stdio\n" +# PR #52, run 36894209966, job 110477452533. The tail arrived after guest1. +INTERJECTION = ( + BANNER + + b"freertos_guest1: alive\r\n" + + b"freertos_guest1: ffm sha256 ok first=0x20\r\n" + + b"freertos_guest1: ffm rng ok\r\n" + + b"freertos_guest1: psa_crypto_init st=0\r\n" + + b"freertos_guest1: psa rng ok\r\n" + + b"freertos_guest1: psa hash ok\r\n" + + b"freertos_guest1: ffm forged-handle rejected\r\n" + + b"freertos_guest1: ffm oversized-vector rejected\r\n" + + b"freertos_guest1: ffm cross-guest vector rejected\r\n" + + b"freertos_guest1: ffm wrong-sid refused\r\n" +) + + +class ConsoleTests(unittest.TestCase): + def test_ci_split_rejoins_exactly_without_modifying_the_raw_log(self): + raw = PREFIX + INTERJECTION + TAIL + self.assertNotIn(MARKER, raw) + self.assertEqual(CONSOLE.guest0_console(raw), PREFIX + TAIL) + self.assertIn(b"freertos_guest1: ffm oversized-vector rejected", raw) + + def test_missing_marker_bytes_still_fail(self): + for index in range(len(MARKER)): + with self.subTest(index=index): + damaged = MARKER[:index] + MARKER[index + 1:] + split = len(damaged) // 2 + raw = damaged[:split] + INTERJECTION + damaged[split:] + b"\n" + self.assertNotIn(MARKER, CONSOLE.guest0_console(raw)) + self.assertNotIn(MARKER, CONSOLE.guest0_console(PREFIX + INTERJECTION)) + + def test_unrelated_lines_do_not_form_a_marker(self): + for ending in (b"\n", b"\r\n"): + with self.subTest(ending=ending): + raw = PREFIX + ending + INTERJECTION + TAIL + self.assertNotIn(MARKER, CONSOLE.guest0_console(raw)) + + def test_complete_interjections_at_every_marker_position(self): + for index in range(len(MARKER) + 1): + for injection in (BANNER, INTERJECTION, + b"freertos_guest1: heartbeat 0\n"): + with self.subTest(index=index, injection=injection): + raw = MARKER[:index] + injection + MARKER[index:] + b"\n" + self.assertEqual(CONSOLE.guest0_console(raw), MARKER + b"\n") + + def test_banner_inside_guest1_record(self): + raw = PREFIX + b"freertos_guest1: heart" + BANNER + b"beat 0\n" + TAIL + self.assertEqual(CONSOLE.guest0_console(raw), PREFIX + TAIL) + + def test_conformance_totals_keep_their_line_boundaries(self): + raw = (b"TOTAL PASSED : 85\r\nTOTAL SK" + INTERJECTION + + b"IPPED : 4\r\nTOTAL FAILED : 0\r\n") + expected = (b"TOTAL PASSED : 85\r\nTOTAL SKIPPED : 4\r\n" + b"TOTAL FAILED : 0\r\n") + self.assertEqual(CONSOLE.guest0_console(raw), expected) + + def test_confboot_pipeline_reads_reconstructed_per_test_results(self): + target = HELPER.parent.parent + expected = b"".join( + line + b"\n" for line in + (target / "ffm_ipc_results.txt").read_bytes().splitlines() + if line and not line.startswith(b"#") + ) + raw = b"" + for line in expected.splitlines(): + num, result = line.split() + raw += (b"Num=" + num[:1] + INTERJECTION + num[1:] + + b" Result=" + result[:3] + BANNER + result[3:] + + b"\r\n") + # Exercise the runner's actual extraction pipeline without booting. + runner = (target / "run_m33mu_scenario.sh").read_text() + pipeline = runner.split('\n conf_got=', 1)[1] + pipeline = 'conf_got=' + pipeline.split('\n if grep -v', 1)[0] + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + (repo / "build").mkdir() + (repo / "raw.log").write_bytes(raw) + (repo / "guest0.log").write_bytes(CONSOLE.guest0_console(raw)) + script = ('set -euo pipefail\nrepo=$1\nlog="$repo/raw.log"\n' + 'guest0_log="$repo/guest0.log"\n' + pipeline) + completed = subprocess.run( + ["bash", "-c", script, "confboot-test", str(repo)], + capture_output=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual((repo / "build/ffm-ipc-results.txt").read_bytes(), + expected) + + def test_pty_and_crlf_banner(self): + banner = b"[UART] 44002400 attached to /dev/pts/17\r\n" + self.assertEqual(CONSOLE.guest0_console(PREFIX + banner + TAIL), PREFIX + TAIL) + + def test_incomplete_or_unrecognized_records_are_preserved(self): + for raw in (BANNER[:-1], b"freertos_guest1: alive", + b"[UART] 40004800 attached to stdio EXTRA\n", + b"[UART] 4000480 attached to stdio\n", + b"freertos_guest0: alive\n"): + with self.subTest(raw=raw): + self.assertEqual(CONSOLE.guest0_console(raw), raw) + + def test_fault_and_unrelated_bytes_are_preserved(self): + raw = b"prefix\xff\r\n[MEMFAULT] addr=0x30028000\n[HARDFLT]\nHardFault\n" + self.assertEqual(CONSOLE.guest0_console(raw), raw) + + def test_guest1_alone_cannot_supply_guest0_marker(self): + raw = b"freertos_guest1: " + MARKER + b"\n" + self.assertNotIn(MARKER, CONSOLE.guest0_console(raw)) + + def test_cli_keeps_raw_file_and_exits_nonzero_for_missing_input(self): + raw = PREFIX + INTERJECTION + TAIL + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "raw.log" + path.write_bytes(raw) + result = subprocess.run([sys.executable, str(HELPER), str(path)], + capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, PREFIX + TAIL) + self.assertEqual(path.read_bytes(), raw) + missing = subprocess.run([sys.executable, str(HELPER), str(path) + ".missing"], + capture_output=True) + self.assertNotEqual(missing.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/target/lib/m33mu_console.py b/tests/target/lib/m33mu_console.py new file mode 100644 index 00000000..4e3bca21 --- /dev/null +++ b/tests/target/lib/m33mu_console.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# m33mu_console.py +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of wolfTrust. +# +# wolfTrust is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# wolfTrust is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +"""Remove complete console interjections for guest0 marker checks. + +Both H5 guests write USART3. Guest1 can disable/re-enable it and print while +guest0 has a line in progress. Remove the attach banner and tagged guest1 +records with their line endings, retaining every other byte and line ending. +The original log remains the evidence for guest1 and fault checks. Arbitrary +interleaving, including an incomplete interjection, must still fail to match. +""" + +import argparse +from pathlib import Path +import re +import sys + + +UART_ATTACH = re.compile( + rb"\[UART\] [0-9a-fA-F]{8} attached to (?:stdio|/dev/pts/[0-9]+)\r?\n" +) +GUEST1_RECORD = re.compile(rb"freertos_guest1:[^\r\n]*\r?\n") + + +def guest0_console(log: bytes) -> bytes: + # Remove banners first: a reopen can also split a guest1 record. + return GUEST1_RECORD.sub(b"", UART_ATTACH.sub(b"", log)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("log", type=Path) + args = parser.parse_args() + sys.stdout.buffer.write(guest0_console(args.log.read_bytes())) + + +if __name__ == "__main__": + main() diff --git a/tests/target/run_m33mu_scenario.sh b/tests/target/run_m33mu_scenario.sh index 9f199bd3..3583f0b2 100755 --- a/tests/target/run_m33mu_scenario.sh +++ b/tests/target/run_m33mu_scenario.sh @@ -383,6 +383,11 @@ emu_status=${PIPESTATUS[0]} set -e echo "wolfBoot/wolfTrust M33MU exit status: $emu_status" +# Preserve raw evidence for guest1 and fault checks. Rejoin guest0 lines only +# across complete, recognized console interjections; retain normal newlines. +guest0_log="$repo/ci-m33mu-$scenario-guest0.log" +python3 "$repo/tests/target/lib/m33mu_console.py" "$log" > "$guest0_log" + # Per-assertion reporting so make test-target surfaces what each scenario # actually checks, not just a single PASS. The Makefile greps these tagged # lines out of the log; the full boot log stays underneath. @@ -390,14 +395,8 @@ check_pass() { printf ' [check] PASS %s\n' "$1"; } check_fail() { printf ' [check] FAIL %s (%s)\n' "$1" "$2"; exit 1; } expect() { if grep -Fq "$2" "$log"; then check_pass "$1"; \ else check_fail "$1" "missing: $2"; fi; } -# Shared-UART tolerant match: guest1's console, or the emulator's own -# "[UART] ... attached" note when guest1 first opens it, can interject -# mid-line in a secure or guest0 print (e.g. "TOTAL SKIPPED : 4"), so strip both and rejoin split lines before requiring -# the exact bytes. -expect_flat() { if sed -e 's/freertos_guest1:.*$//' \ - -e 's/\[UART\] [0-9a-f]* attached to [^ ]*//' "$log" | \ - tr -d '\r\n' | grep -Fq "$2"; then check_pass "$1"; \ +# Shared-UART tolerant match against the same guest0 view used by CI. +expect_flat() { if grep -Fq "$2" "$guest0_log"; then check_pass "$1"; \ else check_fail "$1" "missing: $2"; fi; } refute_re() { if grep -Eq "$2" "$log"; then check_fail "$1" "unexpected: $2"; \ else check_pass "$1"; fi; } @@ -502,31 +501,31 @@ case "$scenario" in refute_re "no fault markers in boot log" \ '^(\[MEMFAULT\]|\[HARDFLT\]|HardFault|SecureFault)' fi - expect "TEE client initialized" "wolfTrust TEE client initialized" - expect "FF-M psa_framework_version=0x0100" \ + expect_flat "TEE client initialized" "wolfTrust TEE client initialized" + expect_flat "FF-M psa_framework_version=0x0100" \ "wolfTrust FF-M psa_framework_version=0x0100" - expect "mediated crypto dispatch verified" \ + expect_flat "mediated crypto dispatch verified" \ "wolfTrust FF-M mediated crypto dispatch verified" - expect "ITS set/get verified" \ + expect_flat "ITS set/get verified" \ "wolfTrust ITS set/get verified" - expect "PS sealed set/get verified" \ + expect_flat "PS sealed set/get verified" \ "wolfTrust PS sealed set/get verified" - expect "key-ops sign/verify verified" \ + expect_flat "key-ops sign/verify verified" \ "wolfTrust key-ops sign/verify verified" - expect "key negatives verified" \ + expect_flat "key negatives verified" \ "wolfTrust key negatives verified" expect_flat "forged-handle call rejected" \ "wolfTrust FF-M forged-handle call rejected" expect_flat "oversized-vector call rejected" \ "wolfTrust FF-M oversized-vector call rejected" - expect "psa_hash_compute(SHA-256) KAT verified" \ + expect_flat "psa_hash_compute(SHA-256) KAT verified" \ "psa_hash_compute(SHA-256) KAT verified" - expect "psa_initial_attestation st=0" "psa_initial_attestation st=0" - expect "attestation COSE_Sign1 verified" \ + expect_flat "psa_initial_attestation st=0" "psa_initial_attestation st=0" + expect_flat "attestation COSE_Sign1 verified" \ "wolfTrust attestation: COSE_Sign1 verified" - expect "token measurement equals wolfBoot measurement of the signed image" \ + expect_flat "token measurement equals wolfBoot measurement of the signed image" \ "wolfTrust attestation: token measurement=$WT_EXPECTED_MEASUREMENT_HEX" - expect "attestation fields verify=0 lifecycle=0x1000 measurement=ok cose=ES256" \ + expect_flat "attestation fields verify=0 lifecycle=0x1000 measurement=ok cose=ES256" \ "attestation verify=0 challenge=ok identity=ok lifecycle=0x1000 measurement=ok cose=ES256" expect "guest1 FF-M SHA-256 KAT through SERVICE_CRYPTO (P7-S3)" \ "freertos_guest1: ffm sha256 ok" @@ -653,8 +652,7 @@ case "$scenario" in "$conf_want was not recorded for psa-arch-tests $conf_rev" fi conf_got="$repo/build/ffm-ipc-results.txt" - sed 's/freertos_guest1:.*$//' "$log" | tr -d '\r\n' | \ - grep -aoE 'Num=[0-9]+|Result=[A-Za-z]+' | \ + grep -aoE 'Num=[0-9]+|Result=[A-Za-z]+' "$guest0_log" | \ awk -F= '$1 == "Num" { num = $2 } $1 == "Result" { if (num != "") print num, $2; num = "" }' | \ sort -n -u > "$conf_got" @@ -672,9 +670,9 @@ case "$scenario" in expect "TEE client initialized" "wolfTrust TEE client initialized" expect "conformance val_entry start" \ "wolfTrust FF-M conformance: val_entry start" - # Flatten the shared UART (guest1 can interject mid-line), then read the - # suite totals: every dev_apis storage test must pass or skip, none FAIL. - flat="$(sed 's/freertos_guest1:.*$//' "$log" | tr -d '\r\n')" + # Read suite totals from the reconstructed guest0 console: every storage + # test must pass or skip, none FAIL. + flat="$(cat "$guest0_log")" passed=$(printf '%s' "$flat" | grep -oE 'TOTAL PASSED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) skipped=$(printf '%s' "$flat" | grep -oE 'TOTAL SKIPPED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) failed=$(printf '%s' "$flat" | grep -oE 'TOTAL FAILED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) @@ -692,7 +690,7 @@ case "$scenario" in expect "TEE client initialized" "wolfTrust TEE client initialized" expect "conformance val_entry start" \ "wolfTrust FF-M conformance: val_entry start" - flat="$(sed 's/freertos_guest1:.*$//' "$log" | tr -d '\r\n')" + flat="$(cat "$guest0_log")" passed=$(printf '%s' "$flat" | grep -oE 'TOTAL PASSED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) skipped=$(printf '%s' "$flat" | grep -oE 'TOTAL SKIPPED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) failed=$(printf '%s' "$flat" | grep -oE 'TOTAL FAILED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) @@ -716,7 +714,7 @@ case "$scenario" in "wolfTrust FF-M conformance: val_entry start" # test_a001 is the whole suite: get_token/get_token_size across all # challenge sizes plus val's own COSE_Sign1 verify of the returned token. - flat="$(sed 's/freertos_guest1:.*$//' "$log" | tr -d '\r\n')" + flat="$(cat "$guest0_log")" passed=$(printf '%s' "$flat" | grep -oE 'TOTAL PASSED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) failed=$(printf '%s' "$flat" | grep -oE 'TOTAL FAILED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) : "${passed:=-1}"; : "${failed:=-1}" @@ -774,7 +772,7 @@ case "$scenario" in # read over SWD on the H5 board; the emulator asserts the suite instead.) expect "conformance val_entry start" \ "wolfTrust FF-M conformance: val_entry start" - flat="$(sed 's/freertos_guest1:.*$//' "$log" | tr -d '\r\n')" + flat="$(cat "$guest0_log")" passed=$(printf '%s' "$flat" | grep -oE 'TOTAL PASSED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) skipped=$(printf '%s' "$flat" | grep -oE 'TOTAL SKIPPED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true) failed=$(printf '%s' "$flat" | grep -oE 'TOTAL FAILED[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1 || true)