From f5ad8a0b9ed5abc849efee04c25b7c7335fc0c9e Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Wed, 23 Sep 2026 09:19:45 +0100 Subject: [PATCH] Stop cyclic shell color references Resolve color references iteratively and use the caller's fallback when a reference repeats. Preserve normal palette roles, color parsing and gradient first stops. Reported-by: Aleksej --- shell/Commons/Color.qml | 8 ++++- test/shell.d/color-reference-test.sh | 52 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/color-reference-test.sh diff --git a/shell/Commons/Color.qml b/shell/Commons/Color.qml index 54163a40..4fd2452b 100644 --- a/shell/Commons/Color.qml +++ b/shell/Commons/Color.qml @@ -49,9 +49,15 @@ QtObject { } function flatColor(value, fallback) { + var seen = Object.create(null) var token = firstColorToken(value) var role = String(token || "").replace(/^\s+|\s+$/g, "").toLowerCase() - if (root.shellValues[role] && root.shellValues[role] !== token) return flatColor(root.shellValues[role], fallback) + while (root.shellValues[role] && root.shellValues[role] !== token) { + if (seen[role]) return fallback + seen[role] = true + token = firstColorToken(root.shellValues[role]) + role = String(token || "").replace(/^\s+|\s+$/g, "").toLowerCase() + } if (role === "foreground" || role === "text") return root.foreground if (role === "accent") return root.accent if (role === "urgent") return root.urgent diff --git a/test/shell.d/color-reference-test.sh b/test/shell.d/color-reference-test.sh new file mode 100755 index 00000000..498f6789 --- /dev/null +++ b/test/shell.d/color-reference-test.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const vm = require('vm') + +const source = fs.readFileSync(path.join(root, 'shell/Commons/Color.qml'), 'utf8') +const geometry = fs.readFileSync(path.join(root, 'shell/Commons/BorderGeometry.js'), 'utf8').replace(/^\.pragma library\n/, '') +const palette = { foreground: '#eeeeee', background: '#111111', accent: '#abcdef', urgent: '#ff0000', muted: '#777777' } +const context = vm.createContext({ root: { ...palette, shellValues: {} }, Geometry: {} }) +vm.runInNewContext(geometry, context.Geometry) +for (const name of ['firstColorToken', 'flatColor', 'parseShell']) { + const fn = source.match(new RegExp(` function ${name}\\([^]*?\\n }`)) + if (!fn) fail(`Color.qml provides ${name}`) + vm.runInContext(fn[0], context) +} + +function check(values, input, expected, description) { + context.root.shellValues = values + context.input = input + const result = vm.runInContext('flatColor(input, "#123456")', context, { timeout: 1000 }) + assertEqual(result, expected, description) +} + +const cycle = context.parseShell('[bar]\nbackground = "menu.background"\n[menu]\nbackground = "bar.background"') +check(cycle, 'bar.background', '#123456', 'two-entry theme cycle returns the supplied fallback') +check({ 'a.color': 'b.color', 'b.color': 'c.color', 'c.color': 'a.color' }, 'a.color', '#123456', 'longer cycle returns fallback') +check({ 'start.color': 'a.color', 'a.color': 'b.color', 'b.color': 'a.color' }, 'start.color', '#123456', 'chain entering a cycle returns fallback') +check({ 'a.color': 'a.color' }, 'a.color', '#123456', 'direct self-reference returns fallback') +check({ 'a.color': ' A.COLOR ' }, 'a.color', '#123456', 'normalized self-reference returns fallback') +check({ 'a.color': '45deg b.color #ffffff', 'b.color': 'a.color' }, 'a.color', '#123456', 'gradient first-stop cycle returns fallback') + +check({ 'a.color': 'b.color', 'b.color': '#abcdef' }, 'a.color', '#abcdef', 'acyclic references preserve the final color') +check({ 'a.color': ' B.COLOR ', 'b.color': '#abcdef' }, 'A.COLOR', '#abcdef', 'each reference keeps case and whitespace normalization') +check({ 'a.color': 'b.color', 'b.color': 'accent' }, 'a.color', palette.accent, 'acyclic references preserve palette roles') +check({ 'a.color': '45deg rgba(11223380) #ffffff' }, 'a.color', '#11223380', 'referenced gradient keeps its first color and alpha') +check({ 'a.color': 'missing.color' }, 'a.color', '#123456', 'missing reference returns fallback') +check({}, '', '#123456', 'empty input returns fallback') +for (const [role, color] of Object.entries(palette)) check({}, role, color, `palette role ${role} is unchanged`) +check({}, 'text', palette.foreground, 'text alias is unchanged') +for (const [input, output] of [['#abc', '#aabbcc'], ['#abcdef', '#abcdef'], ['rgb(abcdef)', '#abcdef'], ['rgba(11223380)', '#11223380']]) { + check({}, input, output, `direct color ${input} is unchanged`) +} + +const chain = {} +for (let i = 0; i < 10000; i++) chain[`chain.c${i}`] = `chain.c${i + 1}` +chain['chain.c10000'] = '#abcdef' +check(chain, 'chain.c0', '#abcdef', 'long acyclic chains do not exhaust the call stack') +check({ 'a.color': '#abcdef' }, 'a.color', '#abcdef', 'visited references do not leak between resolutions') +JS