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)