From 987ff5c9ca350998a8f07c55f5e3b3d31e37f624 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 13:32:27 -0400 Subject: [PATCH] Fix saltclass literal '^' list-override marker for single class The '^' list-override marker was only stripped when the target dict already contained a matching list to override. When a class defined an override list with no prior list present, dict_merge plain-assigned the value (including the nested "pillars" dict) by reference and never descended to the list, leaving a literal '^' element in the merged pillar. Descend into dicts and honour a leading '^' on a list in the key-absent branch as well. Fixes #50755 --- changelog/50755.fixed.md | 1 + salt/utils/saltclass.py | 11 +++- tests/pytests/unit/utils/test_saltclass.py | 63 ++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 changelog/50755.fixed.md create mode 100644 tests/pytests/unit/utils/test_saltclass.py diff --git a/changelog/50755.fixed.md b/changelog/50755.fixed.md new file mode 100644 index 000000000000..0a2278408716 --- /dev/null +++ b/changelog/50755.fixed.md @@ -0,0 +1 @@ +Fixed saltclass leaving a literal ``^`` list-override marker in the merged pillar when a list is overridden by a single class and no existing list is present to override. diff --git a/salt/utils/saltclass.py b/salt/utils/saltclass.py index 25b7a838fc16..dde313a16d84 100644 --- a/salt/utils/saltclass.py +++ b/salt/utils/saltclass.py @@ -134,7 +134,16 @@ def dict_merge(a, b, path=None): else: a[key] = b[key] else: - a[key] = b[key] + # Key is absent from a. Descend into dicts so nested override + # markers are still processed, and honour a leading '^' marker on + # a list even when there is no existing list to override. + if isinstance(b[key], list) and b[key] and b[key][0] == "^": + a[key] = b[key][1:] + elif isinstance(b[key], dict): + a[key] = {} + dict_merge(a[key], b[key], path + [str(key)]) + else: + a[key] = b[key] return a diff --git a/tests/pytests/unit/utils/test_saltclass.py b/tests/pytests/unit/utils/test_saltclass.py new file mode 100644 index 000000000000..145ab730d4b3 --- /dev/null +++ b/tests/pytests/unit/utils/test_saltclass.py @@ -0,0 +1,63 @@ +import salt.utils.saltclass as saltclass + + +def test_dict_merge_list_override_single_class_50755(): + """ + A leading '^' override marker on a list must be honoured even when the + target dict has no existing list to override (single-class case). + + This mirrors the production call in expanded_dict_from_minion, where the + caller does dict_merge(pillars_dict, exp_dict) with an initially empty + pillars_dict and the override list nested under the "pillars" key. + """ + assert saltclass.dict_merge({}, {"pillars": {"pkgs": ["^", "three"]}}) == { + "pillars": {"pkgs": ["three"]} + } + + +def test_dict_merge_list_override_deeper_nesting_50755(): + """ + The override marker must be stripped for a list nested at arbitrary depth + below an absent key, not only directly under "pillars". + """ + assert saltclass.dict_merge({}, {"a": {"b": ["^", "x"]}}) == {"a": {"b": ["x"]}} + + +def test_dict_merge_list_override_key_present_50755(): + """ + Inverse / must-not-regress: the override marker already worked when the + target dict contained a matching list. This passes with and without the + fix and guards the existing list+list code path. + """ + assert saltclass.dict_merge( + {"pillars": {"pkgs": ["one", "two"]}}, + {"pillars": {"pkgs": ["^", "three"]}}, + ) == {"pillars": {"pkgs": ["three"]}} + + +def test_dict_merge_plain_list_key_absent_no_marker_50755(): + """ + Inverse / must-not-regress: a marker-free list assigned into an absent key + must be copied through verbatim. Passes with and without the fix. + """ + assert saltclass.dict_merge({}, {"pkgs": ["one", "two"]}) == { + "pkgs": ["one", "two"] + } + + +def test_dict_merge_empty_list_key_absent_50755(): + """ + Peripheral coverage: an empty list assigned into an absent key must not + raise IndexError while checking for the '^' marker. + """ + assert saltclass.dict_merge({}, {"pkgs": []}) == {"pkgs": []} + + +def test_dict_merge_plain_extend_key_present_50755(): + """ + Peripheral coverage: marker-free lists on a present key are extended, not + replaced. This is the default (non-override) merge behaviour. + """ + assert saltclass.dict_merge({"pkgs": ["one"]}, {"pkgs": ["two"]}) == { + "pkgs": ["one", "two"] + }