Skip to content

Commit ba3d6c3

Browse files
authored
fix(ptp-m3e): fail-closed SOURCE readiness and boot provenance (#223)
* fix(ptp-m3e): reject stale SOURCE readiness and incomplete boot evidence * test(ptp-m3e): use actual serial line breaks in SOURCE fixtures
1 parent 993bace commit ba3d6c3

3 files changed

Lines changed: 96 additions & 14 deletions

File tree

‎docs/PTP_M3E_BENCH_EVIDENCE_GATE.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,22 @@ and canonical PCAP continuity. All hardware qualification fields remain
111111

112112
All M3E.2 changes are Python host-tool regression/doc additions. The proven
113113
M3D.2 SOURCE/RECEIVER firmware binaries and native ARStack Studio are not
114-
rebuilt or modified by this checkpoint.
114+
rebuilt or modified by this checkpoint.
115+
## M3E.3 — SOURCE readiness freshness / boot provenance regression (2026-10-17)
116+
117+
A previous host gate accepted **any** historical `PTP status=RUNNING`
118+
sample with positive Announce/Sync/FollowUp as SOURCE readiness. This could
119+
return `operationalLifecycle.status=OBSERVED` even when a later STOP, rejected
120+
START, unavailable status, or TX fault was already present in the same raw log.
121+
122+
The M3E.3 host-only gate now requires the **latest** SOURCE status to be
123+
RUNNING with complete native firmware counters and `TXfail=0`. It revokes
124+
readiness on `PTP stop requested`, `PTP start rejected`, STOPPED, truncated
125+
SHOW, or unavailable status. Any nonzero reported PTP TX failure in the same
126+
boot is conservatively latched as a veto. Every IDENTIFY line must also carry
127+
the same 16-hex-digit boot ID; an identity without a boot ID cannot inherit
128+
a preceding measurement. Receiver logic and wire decoding are unchanged.
129+
130+
This is regression protection for **firmware-reported** software evidence,
131+
not proof of wire transmission, EMAC phase alignment, IED lock, or public
132+
release readiness. No firmware images or Studio workflow have changed.

‎scripts/ptp_m3e_bench_gate.py‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
SCENARIOS = ("auto", "receiver-acquisition", "receiver-recovery",
2828
"receiver-gm-handoff", "source-ready")
2929
BOOT_ID = re.compile(r"\bboot_id=([a-fA-F0-9]{16})\b")
30-
SOURCE_READY = re.compile(
31-
r"\bPTP status=RUNNING Announce=([0-9]+) Sync=([0-9]+) "
32-
r"FollowUp=([0-9]+)\b")
30+
SOURCE_STATUS = re.compile(
31+
r"\bPTP status=(RUNNING|STOPPED) Announce=([0-9]+) Sync=([0-9]+) "
32+
r"FollowUp=([0-9]+) PdelayFrames=([0-9]+) TXfail=([0-9]+)\b")
33+
SOURCE_INVALIDATION = re.compile(
34+
r"\bPTP (?:stop requested|start rejected|status unavailable)\b")
3335
STATUS_FIELD = re.compile(r"\b([A-Za-z][A-Za-z0-9]*)=([^\s]+)")
3436

3537
class EvidenceError(ValueError):
@@ -203,24 +205,49 @@ def operational_evidence(data: bytes, role: str, scenario: str) -> dict:
203205
require((role == "source") == (scenario == "source-ready"),
204206
"scenario incompatible with firmware role")
205207
lines = data.decode("utf-8", errors="replace").splitlines()
206-
boot_ids = [m.group(1).lower() for line in lines
207-
if IDENTITY.search(line) and (m := BOOT_ID.search(line))]
208-
if len(set(boot_ids)) != 1 or not boot_ids:
208+
identity_lines = [line for line in lines if IDENTITY.search(line)]
209+
boot_ids = [m.group(1).lower() for line in identity_lines
210+
if (m := BOOT_ID.search(line))]
211+
# Every board identity must belong to the same observed boot. A later
212+
# identity missing boot_id must not silently inherit the previous lease.
213+
if (not boot_ids or len(boot_ids) != len(identity_lines) or
214+
len(set(boot_ids)) != 1):
209215
return {"status": "INCOMPLETE", "scenario": scenario,
210216
"reason": "one unambiguous 16-digit boot_id required",
211217
"reportedLockSamples": 0, "reportedRecovery": False,
212218
"reportedGmHandoff": False}
213219

214220
if role == "source":
215-
ready = [tuple(int(x) for x in m.groups())
216-
for line in lines if (m := SOURCE_READY.search(line))]
217-
usable = any(all(x > 0 for x in values) for values in ready)
221+
# A historical RUNNING line is not current readiness. The actual
222+
# firmware SHOW includes TXfail and PdelayFrames; partial output is
223+
# never acceptable. STOP, rejected START and unavailable status
224+
# invalidate all prior ready observations.
225+
last_ready = False
226+
tx_fault_observed = False
227+
ready_samples = 0
228+
for line in lines:
229+
if SOURCE_INVALIDATION.search(line):
230+
last_ready = False
231+
if "PTP status=" not in line:
232+
continue
233+
match = SOURCE_STATUS.search(line)
234+
if match is None:
235+
last_ready = False
236+
continue
237+
state, *reported = match.groups()
238+
announce, sync, followup, _pdelay, txfail = map(int, reported)
239+
tx_fault_observed = tx_fault_observed or txfail != 0
240+
last_ready = (state == "RUNNING" and
241+
announce > 0 and sync > 0 and followup > 0 and
242+
txfail == 0)
243+
ready_samples += int(last_ready)
244+
usable = last_ready and not tx_fault_observed
218245
return {"status": "OBSERVED" if usable else "INCOMPLETE",
219246
"scenario": scenario,
220-
"reason": "source Announce+Sync+FollowUp TX readiness reported"
221-
if usable else "verified source TX readiness evidence missing",
222-
"reportedSourceReadySamples": sum(all(x > 0 for x in v)
223-
for v in ready),
247+
"reason": "latest SOURCE status RUNNING with successful PTP TX"
248+
if usable else "latest SOURCE readiness absent, stopped or TX faulted",
249+
"reportedSourceReadySamples": ready_samples,
250+
"sourceTxFaultObserved": tx_fault_observed,
224251
"reportedLockSamples": 0,
225252
"reportedRecovery": False, "reportedGmHandoff": False}
226253

‎tests/test_ptp_m3e_bench_gate.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,43 @@ def test_source_requires_actual_sent_ptp_triplet(self):
319319
"source-ready", "source")
320320
self.assertEqual(complete["status"], "OBSERVED")
321321

322+
def test_source_readiness_is_latest_not_any_historical_running(self):
323+
prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n"
324+
"PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n")
325+
running = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n"
326+
for suffix in ("PTP stop requested\n",
327+
"PTP status=STOPPED Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n",
328+
"PTP status unavailable\n",
329+
"PTP start rejected: link down\n",
330+
"PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0\n"):
331+
with self.subTest(suffix=suffix):
332+
actual = self.run_gate((prefix + running + suffix).encode(),
333+
"source-ready", "source")
334+
self.assertEqual(actual["status"], "INCOMPLETE")
335+
# A later clean RUNNING status is evidence of a newly reported state.
336+
clean_restart = prefix + running + "PTP stop requested\n" + running
337+
self.assertEqual(self.run_gate(clean_restart.encode(), "source-ready",
338+
"source")["status"], "OBSERVED")
339+
340+
def test_source_any_reported_tx_failure_vetoes_same_boot(self):
341+
prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n"
342+
"PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n")
343+
bad = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=1\n"
344+
good = "PTP status=RUNNING Announce=8 Sync=8 FollowUp=8 PdelayFrames=0 TXfail=0\n"
345+
actual = self.run_gate((prefix + bad + good).encode(), "source-ready", "source")
346+
self.assertEqual(actual["status"], "INCOMPLETE")
347+
self.assertTrue(actual["sourceTxFaultObserved"])
348+
self.assertEqual(self.run_gate((prefix + bad).encode(), "source-ready",
349+
"source")["status"], "INCOMPLETE")
350+
351+
def test_missing_boot_id_on_later_identity_fails_closed(self):
352+
data = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n"
353+
"PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n"
354+
f"ARSTACK identity build={HEAD[:16]}\n"
355+
"PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n")
356+
self.assertEqual(self.run_gate(data.encode(), "source-ready",
357+
"source")["status"], "INCOMPLETE")
358+
322359
def test_incompatible_role_scenario_is_rejected(self):
323360
with self.assertRaisesRegex(module.EvidenceError, "incompatible"):
324361
self.run_gate(bench_log("LOCKED"), "source-ready", "receiver")

0 commit comments

Comments
 (0)