fix: make settings reads non-mutating and harden malformed settings handling (#874)
What changed, and why it matters
This commit fixes bugs in how Krux reads and writes its saved settings. Previously, simply reading a setting could silently change the stored data, and a corrupted settings file (for example, one where a category was accidentally a word instead of a group) could crash the app or be misinterpreted. The patch makes reads truly read-only, repairs bad structure when writing, and only accepts known storage locations. It is a defensive hardening change rather than a fix for an active attack, but it removes several ways a tampered or damaged settings file could destabilize the device.
Treat as a defensive-security hardening patch. Review whether persisted settings files can be supplied or modified by untrusted parties (for example, via SD card or firmware update bundles), since that would determine whether the malformed-settings path is reachable in practice. Consider adding integrity protection or signed settings if untrusted input is possible. Otherwise, merge and include in release notes as a robustness improvement.
Security signals we found
Eliminates read-side mutation of settings state that could alter persisted configuration unexpectedly
Hardens settings parsing against corrupted or hand-edited persisted JSON
Validates persist.location against an allow-list of known paths, preventing type errors and unintended storage selection
Repairs non-dict namespace levels on write instead of raising AttributeError
Adds unit tests demonstrating pre-fix crashes and post-fix graceful degradation
Evidence from the diff
The change refactors Store.get() to stop deep-copying and mutating the settings tree on every read; it now walks namespaces defensively and returns the default if any intermediate level is not a dict. Store.init gains _persisted_location(), which validates that settings.persist.location is one of the known paths (SD_PATH or FLASH_PATH) before returning it, preventing non-string/unknown values from reaching a string-membership test. Store.set() and Store.delete() now replace non-dict intermediate levels with fresh dicts instead of crashing with AttributeError. Tests are added for malformed first-level namespaces, malformed nested namespaces, bogus location values, and deep nesting.
Changed components
src/krux/settings.pytests/test_settings.pyStore class (get, set, delete, __init__, _persisted_location)Settings persistence and storage-location selectionInspect captured patch +170 / −22
diff --git a/src/krux/settings.py b/src/krux/settings.py
index 51ff5dd..94d9980 100644
--- a/src/krux/settings.py
+++ b/src/krux/settings.py
@@ -170,11 +170,7 @@ class Store:
self._load_settings()
# Define location based on what was loaded or default undefined
- self.file_location = (
- self.settings.get("settings", {})
- .get("persist", {})
- .get("location", "undefined")
- )
+ self.file_location = self._persisted_location("undefined")
# Settings not found on SD, or 'persist.location' key not defined
if SD_PATH not in self.file_location:
@@ -184,11 +180,25 @@ class Store:
# Settings persist location will point to SD (if defined) else defaults to flash
self.file_location = Store.get_vfs_location(
- self.settings.get("settings", {})
- .get("persist", {})
- .get("location", FLASH_PATH)
+ self._persisted_location(FLASH_PATH)
)
+ def _persisted_location(self, default):
+ """Reads persist.location defensively; returns a known path or default."""
+ # A corrupted/hand-edited file may have a non-dict at any namespace level
+ # or a bogus location value — walk defensively and validate before returning.
+ node = self.settings
+ for level in ("settings", "persist"):
+ if not isinstance(node, dict):
+ return default
+ node = node.get(level)
+ if not isinstance(node, dict):
+ return default
+ location = node.get("location", default)
+ if location not in (SD_PATH, FLASH_PATH):
+ return default
+ return location
+
@classmethod
def get_vfs_location(cls, location):
"""Returns the formatted vfs location for SD/flash"""
@@ -215,24 +225,22 @@ class Store:
pass
def get(self, namespace, setting_name, default_value):
- """Returns a setting value under the given namespace, or default value if not set"""
- s = json.loads(
- json.dumps(self.settings)
- ) # deepcopy to avoid building out namespaces
+ """Returns setting value under the given namespace, or default_value if not set."""
+ s = self.settings
for level in namespace.split("."):
- s[level] = s.get(level, {})
- s = s[level]
- if setting_name not in s:
- return default_value
- return s[setting_name]
+ s = s.get(level)
+ if not isinstance(s, dict):
+ return default_value
+ return s.get(setting_name, default_value)
def set(self, namespace, setting_name, setting_value):
- """Stores a setting value under the given namespace if new/changed.
- Does NOT automatically save settings to flash or sd!
- """
+ """Stores a setting value under the given namespace if new/changed. Does not auto-save."""
+ # A non-dict intermediate level is replaced with a fresh dict,
+ # repairing malformed structure.
s = self.settings
for level in namespace.split("."):
- s[level] = s.get(level, {})
+ if not isinstance(s.get(level), dict):
+ s[level] = {}
s = s[level]
old_value = s.get(setting_name, None)
if old_value != setting_value:
@@ -246,7 +254,8 @@ class Store:
s = self.settings
levels = []
for level in namespace.split("."):
- s[level] = s.get(level, {})
+ if not isinstance(s.get(level), dict):
+ s[level] = {}
levels.append([s, level])
s = s[level]
if setting_name in s:
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 857962c..f5cd498 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -265,6 +265,145 @@ def test_store_get():
assert s.get(case[0], case[1], case[3]) == case[2]
+def test_store_get_malformed_namespace_returns_default():
+ """A non-dict intermediate namespace must return the default, not raise.
+
+ Covers BOTH a non-dict at the first level AND a non-dict reached after a
+ valid descent (proves traversal stays safe mid-walk). Only reachable via a
+ corrupted/hand-edited settings file; set() never creates this state.
+ Approved behavior change: graceful degradation.
+ """
+ from krux.settings import Store
+
+ s = Store()
+
+ # Case 1: non-dict at the very first level.
+ s.settings = {"settings": "not_a_dict"}
+ assert s.get("settings.i18n", "locale", "en-US") == "en-US"
+ assert s.settings == {"settings": "not_a_dict"} # no mutation
+
+ # Case 2: non-dict reached AFTER successfully descending a valid dict.
+ s.settings = {"settings": {"printer": "not_a_dict"}}
+ assert s.get("settings.printer.thermal", "baudrate", 9600) == 9600
+ assert s.settings == {"settings": {"printer": "not_a_dict"}} # no mutation
+
+
+def test_store_set_repairs_non_dict_namespace(mocker, m5stickv):
+ """Store.set must not crash when an intermediate namespace is a non-dict
+ (e.g. a corrupted file like {"settings": "broken"}); it replaces the bad
+ level with a dict, stores the value, and repairs the structure.
+ """
+ from krux.settings import Store
+
+ s = Store()
+ s.settings = {"settings": "broken"}
+
+ # Pre-fix this raised AttributeError: 'str' object has no attribute 'get'.
+ s.set("settings.appearance", "theme", "dark")
+
+ assert s.settings["settings"]["appearance"]["theme"] == "dark"
+ assert s.dirty is True
+ # Read-back through the repaired structure returns the stored value.
+ assert s.get("settings.appearance", "theme", "light") == "dark"
+
+
+def test_store_delete_survives_non_dict_namespace(mocker, m5stickv):
+ """Store.delete must not crash when an intermediate namespace is a non-dict."""
+ from krux.settings import Store
+
+ s = Store()
+ s.settings = {"settings": "broken"}
+
+ # Pre-fix this raised AttributeError walking into the string "broken".
+ s.delete("settings.appearance", "theme") # nothing to delete, must not raise
+ # The non-dict "broken" must no longer be present (repaired, then the empty
+ # levels cleaned up by delete's own pruning).
+ assert s.settings.get("settings") != "broken"
+
+
+def test_store_init_survives_non_dict_settings_namespace(mocker, m5stickv):
+ """Store.__init__ must not crash when the persisted 'settings' value is a
+ non-dict.
+
+ The loader only validates the top level is a dict, so a corrupted file like
+ {"settings": "not_a_dict"} passes load. The location read in __init__ walks
+ settings.persist.location and must degrade gracefully instead of raising
+ AttributeError. Reads through the malformed namespace return defaults.
+ """
+ stored_settings = '{"settings": "not_a_dict"}'
+ mocker.patch("builtins.open", mocker.mock_open(read_data=stored_settings))
+
+ from krux.settings import Store, FLASH_PATH
+
+ store = Store() # must not raise
+ assert FLASH_PATH in store.file_location
+ assert store.get("settings.i18n", "locale", "en-US") == "en-US"
+
+
+def test_store_init_survives_bogus_persist_location(mocker, m5stickv):
+ """Store.__init__ must not crash on a bogus persist.location value.
+
+ The structure is well-formed but `location` holds an unexpected value
+ (non-string, or an unknown string). `_persisted_location` must return only a
+ known location (SD/flash) and otherwise fall back to the default, so the
+ `SD_PATH not in self.file_location` membership test never sees a non-string.
+ """
+ from krux.settings import Store, FLASH_PATH
+
+ # Non-string location (would raise "argument of type 'int' is not iterable").
+ mocker.patch(
+ "builtins.open",
+ mocker.mock_open(read_data='{"settings": {"persist": {"location": 123}}}'),
+ )
+ store = Store() # must not raise
+ assert FLASH_PATH in store.file_location
+
+ # Unknown string location -> also falls back to flash.
+ mocker.patch(
+ "builtins.open",
+ mocker.mock_open(read_data='{"settings": {"persist": {"location": "xyz"}}}'),
+ )
+ store = Store() # must not raise
+ assert FLASH_PATH in store.file_location
+
+
+def test_store_get_recovers_from_malformed_persisted_namespace(mocker, m5stickv):
+ """End-to-end: a persisted settings file with a well-formed top level but a
+ non-dict nested namespace loads, and reads through it degrade gracefully.
+
+ The top level, `settings`, and `settings.persist` are well-formed (the
+ constructor reads `persist` to pick the file location), but the `i18n`
+ namespace is a string instead of a dict. get() must return the default
+ rather than raising. Covers the file -> __init__ -> get() chain, not just
+ direct Store.get() calls.
+ """
+ stored_settings = '{"settings": {"i18n": "not_a_dict"}}'
+ mocker.patch("builtins.open", mocker.mock_open(read_data=stored_settings))
+
+ from krux.settings import Store
+
+ store = Store()
+ assert store.settings == {"settings": {"i18n": "not_a_dict"}}
+ assert store.get("settings.i18n", "locale", "en-US") == "en-US"
+
+
+def test_store_get_deep_nesting():
+ """A 4-level namespace returns stored value when set, default when unset."""
+ from krux.settings import Store
+
+ s = Store()
+ ns = "settings.printer.thermal.adafruit"
+
+ # Unset -> default.
+ assert s.get(ns, "tx_pin", 35) == 35
+ # Getter must not populate the settings dict on a miss.
+ assert s.settings == {}
+
+ # Set then get returns the stored value, ignoring the default.
+ s.set(ns, "tx_pin", 21)
+ assert s.get(ns, "tx_pin", 35) == 21
+
+
def test_store_set():
from krux.settings import Store
Why this scored 48/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.