From 19d5431c63086295473c08f2b8ef0a6ad959efdd Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 22 May 2026 14:41:30 -0500 Subject: [PATCH] [APP-2160] Validate hexToRgb input and guard against throws Reject malformed hex strings instead of producing garbled output, drop the alpha channel from 8-digit hex, and wrap the public entrypoints in try/catch so callers always get a usable color string back. --- src/alf/util/colorGeneration.test.ts | 94 ++++++++++++++++++++++++++++ src/alf/util/colorGeneration.ts | 57 +++++++++++------ 2 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 src/alf/util/colorGeneration.test.ts diff --git a/src/alf/util/colorGeneration.test.ts b/src/alf/util/colorGeneration.test.ts new file mode 100644 index 0000000000..c4a2b0bbb5 --- /dev/null +++ b/src/alf/util/colorGeneration.test.ts @@ -0,0 +1,94 @@ +import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration' + +describe('hexToRgb', () => { + it('parses 6-digit hex', () => { + expect(hexToRgb('#abcdef')).toEqual({r: 0xab, g: 0xcd, b: 0xef}) + }) + + it('parses 6-digit hex without leading #', () => { + expect(hexToRgb('abcdef')).toEqual({r: 0xab, g: 0xcd, b: 0xef}) + }) + + it('parses 3-digit shorthand hex', () => { + expect(hexToRgb('#abc')).toEqual({r: 0xaa, g: 0xbb, b: 0xcc}) + }) + + it('parses 8-digit hex by dropping the alpha channel', () => { + expect(hexToRgb('#aabbccdd')).toEqual({r: 0xaa, g: 0xbb, b: 0xcc}) + }) + + it('handles uppercase digits', () => { + expect(hexToRgb('#ABCDEF')).toEqual({r: 0xab, g: 0xcd, b: 0xef}) + }) + + it('returns null for 4-digit shorthand', () => { + expect(hexToRgb('#abcd')).toBeNull() + }) + + it('returns null for non-hex characters', () => { + expect(hexToRgb('#zzzzzz')).toBeNull() + }) + + it('returns null for non-hex words', () => { + expect(hexToRgb('blue')).toBeNull() + }) + + it('returns null for the empty string', () => { + expect(hexToRgb('')).toBeNull() + }) + + it('returns null for unexpected lengths', () => { + expect(hexToRgb('#ab')).toBeNull() + expect(hexToRgb('#abcde')).toBeNull() + expect(hexToRgb('#abcdefg')).toBeNull() + }) +}) + +describe('rgbToHex', () => { + it('formats integer channels', () => { + expect(rgbToHex(0xab, 0xcd, 0xef)).toBe('#abcdef') + }) + + it('rounds floating-point channels', () => { + expect(rgbToHex(170.4, 187.6, 204.5)).toBe('#aabccd') + }) + + it('clamps below zero to 00', () => { + expect(rgbToHex(-10, 0, 0)).toBe('#000000') + }) + + it('clamps above 255 to ff', () => { + expect(rgbToHex(300, 255, 255)).toBe('#ffffff') + }) + + it('zero-pads short hex output', () => { + expect(rgbToHex(0, 0, 0)).toBe('#000000') + }) +}) + +describe('lighten / darken', () => { + it('lighten increases lightness', () => { + expect(lighten('#808080', 10)).toBe('#9a9a9a') + }) + + it('darken decreases lightness', () => { + expect(darken('#808080', 10)).toBe('#676767') + }) + + it('lighten clamps at white', () => { + expect(lighten('#ffffff', 50)).toBe('#ffffff') + }) + + it('darken clamps at black', () => { + expect(darken('#000000', 50)).toBe('#000000') + }) + + it('lighten by zero is a no-op', () => { + expect(lighten('#abcdef', 0)).toBe('#abcdef') + }) + + it('returns the input unchanged for invalid hex', () => { + expect(lighten('not-a-color', 10)).toBe('not-a-color') + expect(darken('#zzz', 10)).toBe('#zzz') + }) +}) diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts index ee1224be6e..85659af25f 100644 --- a/src/alf/util/colorGeneration.ts +++ b/src/alf/util/colorGeneration.ts @@ -9,6 +9,7 @@ export const transparentifyColor = utils.alpha /** * Lighten a hex color by `amount` percentage points of HSL lightness (0-100). + * If `hex` is not a valid hex color, returns the input unchanged. */ export function lighten(hex: string, amount: number): string { return adjustLightness(hex, amount) @@ -16,33 +17,51 @@ export function lighten(hex: string, amount: number): string { /** * Darken a hex color by `amount` percentage points of HSL lightness (0-100). + * If `hex` is not a valid hex color, returns the input unchanged. */ export function darken(hex: string, amount: number): string { return adjustLightness(hex, -amount) } function adjustLightness(hex: string, delta: number): string { - const {r, g, b} = hexToRgb(hex) - const {h, s, l} = rgbToHsl(r, g, b) - const next = clamp(l + delta, 0, 100) - const out = hslToRgb(h, s, next) - return rgbToHex(out.r, out.g, out.b) + try { + const rgb = hexToRgb(hex) + if (!rgb) return hex + const {h, s, l} = rgbToHsl(rgb.r, rgb.g, rgb.b) + const next = clamp(l + delta, 0, 100) + const out = hslToRgb(h, s, next) + return rgbToHex(out.r, out.g, out.b) + } catch { + return hex + } } -function hexToRgb(hex: string): {r: number; g: number; b: number} { - const h = hex.replace('#', '') - const expanded = - h.length === 3 - ? h - .split('') - .map(c => c + c) - .join('') - : h - const num = parseInt(expanded, 16) - return { - r: (num >> 16) & 255, - g: (num >> 8) & 255, - b: num & 255, +const HEX_PATTERN = /^([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/ + +export function hexToRgb( + hex: string, +): {r: number; g: number; b: number} | null { + try { + if (typeof hex !== 'string') return null + const h = hex.startsWith('#') ? hex.slice(1) : hex + if (!HEX_PATTERN.test(h)) return null + // 8-digit hex carries an alpha channel we don't use; drop it + const rgb = h.length === 8 ? h.slice(0, 6) : h + const expanded = + rgb.length === 3 + ? rgb + .split('') + .map(c => c + c) + .join('') + : rgb + const num = parseInt(expanded, 16) + return { + r: (num >> 16) & 255, + g: (num >> 8) & 255, + b: num & 255, + } + } catch { + return null } }