From 1861cb8198075d020e25d5d155fbb366e2b9f45b Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Fri, 31 Jul 2026 10:17:50 +0200 Subject: [PATCH] fix: align tombstone key validation --- src/reconcile.ts | 8 ++++++-- test/reconcile.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/reconcile.ts b/src/reconcile.ts index 5d2ac78..007b69d 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -1,5 +1,5 @@ import { KeyfoldMergeError } from "./errors.js"; -import { isPlainObject } from "./objects.js"; +import { enumerableOwnKeys, isPlainObject, UNSAFE_KEYS } from "./objects.js"; import type { PolicyNode } from "./paths.js"; import { DELETE_TOKEN } from "./sentinels.js"; @@ -143,8 +143,12 @@ function isTombstone(item: Record, identityField: string, path: ); } - for (const key of Object.keys(item)) { + // Purity is judged by the same key set the fold would merge: symbol keys + // are patch data, while unsafe keys are ignored everywhere and so cannot + // taint a tombstone. + for (const key of enumerableOwnKeys(item)) { if (key === identityField || key === "$delete") continue; + if (typeof key === "string" && UNSAFE_KEYS.has(key)) continue; throw new KeyfoldMergeError( `tombstone at '${path}' must contain only '${identityField}' and '$delete'`, ); diff --git a/test/reconcile.test.ts b/test/reconcile.test.ts index 61c8c54..cb528af 100644 --- a/test/reconcile.test.ts +++ b/test/reconcile.test.ts @@ -211,6 +211,7 @@ describe("ambiguity and failure safety", () => { ], ["mixed tombstone", [{ id: "a", $delete: true, quantity: 2 }]], ["mixed tombstone with undefined field", [{ id: "a", $delete: true, quantity: undefined }]], + ["mixed tombstone with symbol-keyed field", [{ id: "a", $delete: true, [Symbol("patch")]: 2 }]], ["non-true tombstone", [{ id: "a", $delete: false }]], ])("throws for %s without mutating base", (_case, items) => { const base = state(); @@ -271,6 +272,16 @@ describe("ambiguity and failure safety", () => { expect(mergeUnknown(base, delta)).toEqual({ items: [{ id: "a", value: 1 }] }); }); + test("ignores unsafe keys on a tombstone like on any other delta object", () => { + const base = state(); + const wireTombstone: unknown = JSON.parse('{"id":"a","$delete":true,"__proto__":{"x":1}}'); + const next = merge(base, { + items: [wireTombstone, { id: "b", $delete: true, constructor: { x: 1 } }], + } as unknown as Delta); + + expect(next.items).toEqual([]); + }); + test("rejects the reserved tombstone field in base data", () => { const base = state(); (base.items[0] as unknown as Record).$delete = false;