From 51b7948887549cbdcce9d12e2f0cc11c4b87d03f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 3 Oct 2026 22:44:03 -0700 Subject: [PATCH] Update preference snapshot CI contract for bounded value copies --- ...companion_settings_persistence_contract.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/test/test_companion_settings_persistence_contract.py b/test/test_companion_settings_persistence_contract.py index d7a3e905b..29d269792 100644 --- a/test/test_companion_settings_persistence_contract.py +++ b/test/test_companion_settings_persistence_contract.py @@ -77,8 +77,17 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase): # A failed read must not leave a partly decoded structure in live use. self.assertRegex( load, - r"CompanionNodePrefs\s+\w+\s*=\s*_prefs\s*;", + r"CompanionNodePrefs\s+loaded_prefs\s*;", ) + snapshot = function_body( + load, + "if (!loaded_prefs.copyPersistedValuesFrom(_prefs))", + ) + self.assertIn("file.close();", snapshot) + self.assertIn("return false;", snapshot) + snapshot_at = load.index("loaded_prefs.copyPersistedValuesFrom(_prefs)") + self.assertLess(snapshot_at, load.index("loaded_prefs.bluetooth_enabled =")) + self.assertLess(snapshot_at, load.index("readField(")) self.assertRegex(load, r"double\s+\w+\s*=\s*node_lat\s*;") self.assertRegex(load, r"double\s+\w+\s*=\s*node_lon\s*;") @@ -93,11 +102,37 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase): self.assertGreaterEqual(load.count("readOptionalField("), 26) self.assertIn("success = success && file.available() == 0;", load) failure_at = load.index("if (!success) return false;") - commit_at = load.index("_prefs = loaded_prefs;", failure_at) + commit_at = load.index("_prefs.copyPersistedValuesFrom(loaded_prefs)", failure_at) + self.assertIn( + "if (!_prefs.copyPersistedValuesFrom(loaded_prefs)) return false;", + load, + ) self.assertLess(failure_at, commit_at) + self.assertLess(commit_at, load.index("node_lat = loaded_lat;")) + self.assertLess(commit_at, load.index("node_lon = loaded_lon;")) + # Whole-object assignment also aliases the snapshot's runtime adapter + # owners. Only a validated value snapshot may reach live preferences. + self.assertNotRegex(load, r"\b_prefs\s*=") self.assertIn("return false;", load) self.assertIn("return true;", load) + def test_preferences_snapshot_excludes_runtime_adapters_before_copy(self): + copy = function_body( + self.prefs, + "bool copyPersistedValuesFrom(const CompanionNodePrefs& source)", + ) + self.assertIn("reinterpret_cast(&airtime_factor)", copy) + self.assertIn("reinterpret_cast(&usb_debug_enabled)", copy) + self.assertIn("reinterpret_cast(&values.radio)", copy) + self.assertIn("reinterpret_cast(&values.custom)", copy) + self.assertIn("size <= radio_begin - begin", copy) + self.assertIn("size <= custom_begin - begin", copy) + self.assertIn("size <= sizeof(values) -", copy) + guarded_at = copy.index("if (!bounded(*this) || !bounded(source)) return false;") + write_at = copy.index("memcpy(&airtime_factor, &source.airtime_factor, size);") + self.assertLess(guarded_at, write_at) + self.assertNotRegex(copy, r"\b(?:radio|custom)\s*=") + def test_startup_never_rewrites_preferences_after_failed_load(self): begin = function_body(self.mesh, "void MyMesh::begin(bool has_display") load_at = begin.index("prefs_ready")