diff --git a/.changeset/frozen-normalized-scopes.md b/.changeset/frozen-normalized-scopes.md new file mode 100644 index 0000000..1dd3c1b --- /dev/null +++ b/.changeset/frozen-normalized-scopes.md @@ -0,0 +1,5 @@ +--- +"@okyrychenko-dev/react-action-guard": patch +--- + +Freeze normalized scope arrays so JavaScript mutation cannot corrupt cached results shared by other consumers. Stable identity, sorting, deduplication, default and empty scope semantics, and bounded cache eviction are preserved. diff --git a/packages/core/src/store/__tests__/scope.test.ts b/packages/core/src/store/__tests__/scope.test.ts index e984948..4bad105 100644 --- a/packages/core/src/store/__tests__/scope.test.ts +++ b/packages/core/src/store/__tests__/scope.test.ts @@ -20,6 +20,17 @@ describe("scope", () => { it("should return the canonical scope list", () => { expect(normalizeScope(scope)).toEqual(expected); }); + + it("should protect cached scopes from JavaScript mutation", () => { + const normalized = normalizeScope(scope); + + expect(Reflect.set(normalized, "0", "poisoned")).toBe(false); + expect(Reflect.set(normalized, "length", 0)).toBe(false); + expect(() => Array.prototype.push.call(normalized, "poisoned")).toThrow(TypeError); + expect(Object.isFrozen(normalized)).toBe(true); + expect(normalizeScope(scope)).toBe(normalized); + expect(normalizeScope(scope)).toEqual(expected); + }); }); it("should reuse the canonical list for equivalent scopes", () => { diff --git a/packages/core/src/store/scope/scope.utils.ts b/packages/core/src/store/scope/scope.utils.ts index 928b3b9..f828273 100644 --- a/packages/core/src/store/scope/scope.utils.ts +++ b/packages/core/src/store/scope/scope.utils.ts @@ -34,7 +34,7 @@ function evictOldestNormalizedScopeCacheEntry(): void { } function getCachedNormalizedScope(scopes: ReadonlyArray): ReadonlyArray { - const normalizedScope = [...new Set(scopes)].sort(); + const normalizedScope = Object.freeze([...new Set(scopes)].sort()); const cacheKey = getNormalizedScopeCacheKey(normalizedScope); const cachedScope = normalizedScopeCache.get(cacheKey); @@ -52,7 +52,7 @@ function getCachedNormalizedScope(scopes: ReadonlyArray): ReadonlyArray< } /** - * Returns a stable, deduplicated, sorted scope list. + * Returns a frozen, stable, deduplicated, sorted scope list. * An omitted scope means global; an empty list remains empty. */ export function normalizeScope(scope?: Scope): ReadonlyArray {