From 337f81234b1f72bbd2ab5c89338689ddacdb825a Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Mon, 5 Feb 2024 19:57:45 +0100 Subject: [PATCH] fix: #401 original data can be mutated by the TransformModal previews --- .../components/modals/TransformModal.svelte | 8 +- src/lib/utils/readonlyProxy.test.ts | 101 ++++++++++++++++++ src/lib/utils/readonlyProxy.ts | 32 ++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 src/lib/utils/readonlyProxy.test.ts create mode 100644 src/lib/utils/readonlyProxy.ts diff --git a/src/lib/components/modals/TransformModal.svelte b/src/lib/components/modals/TransformModal.svelte index 24e621f2..0c894c89 100644 --- a/src/lib/components/modals/TransformModal.svelte +++ b/src/lib/components/modals/TransformModal.svelte @@ -31,6 +31,7 @@ } from '$lib/types.js' import { onEscape } from '$lib/actions/onEscape.js' import type { Context } from 'svelte-simple-modal' + import { readonlyProxy } from '$lib/utils/readonlyProxy.js' const debug = createDebug('jsoneditor:TransformModal') @@ -58,7 +59,7 @@ export let onTransform: OnPatch let selectedJson: unknown | undefined - $: selectedJson = getIn(json, rootPath) + $: selectedJson = readonlyProxy(getIn(json, rootPath)) let selectedContent: Content $: selectedContent = selectedJson ? { json: selectedJson } : { text: '' } @@ -76,7 +77,10 @@ let query = queryLanguageId === state.queryLanguageId && state.query ? state.query - : getSelectedQueryLanguage(queryLanguageId).createQuery(selectedJson, state.queryOptions || {}) + : getSelectedQueryLanguage(queryLanguageId).createQuery( + selectedJson, + state.queryOptions || {} + ) let isManual = state.isManual || false let previewError: string | undefined = undefined diff --git a/src/lib/utils/readonlyProxy.test.ts b/src/lib/utils/readonlyProxy.test.ts new file mode 100644 index 00000000..7655dff1 --- /dev/null +++ b/src/lib/utils/readonlyProxy.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest' +import { readonlyProxy } from '$lib/utils/readonlyProxy.js' + +describe('readonlyProxy', () => { + const objOriginal = createNestedObject() + const obj = createNestedObject() + const proxy = readonlyProxy(obj) + + test('should read a nested property', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(proxy[0].data.value).toEqual(42) + }) + + test('should allow invoking immutable methods', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(proxy.map((item) => item.id)).toEqual([1, 2]) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(proxy.find((item) => item.id === 1)).toEqual(obj[0]) + + const log: unknown[] = [] + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + proxy.forEach((value, index) => log.push({ value, index })) + expect(log).toEqual([ + { value: obj[0], index: 0 }, + { value: obj[1], index: 1 } + ]) + }) + + test('should get all object keys', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(Object.keys(proxy[0])).toEqual(['id', 'data']) + }) + + test('should not allow setting a nested property', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(() => (proxy[0].data.value = 'foo')).toThrow( + new TypeError("'set' on proxy: trap returned falsish for property 'value'") + ) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(() => (proxy[0].data = 'foo')).toThrow( + new TypeError("'set' on proxy: trap returned falsish for property 'data'") + ) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(() => (proxy[0] = null)).toThrow( + new TypeError("'set' on proxy: trap returned falsish for property '0'") + ) + + expect(obj).toEqual(objOriginal) + }) + + test('should not allow deleting a property', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(() => delete proxy[0].data.value).toThrow( + new TypeError("'deleteProperty' on proxy: trap returned falsish for property 'value'") + ) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(() => delete proxy[0]).toThrow( + new TypeError("'deleteProperty' on proxy: trap returned falsish for property '0'") + ) + + expect(obj).toEqual(objOriginal) + }) + + test('should not allow mutable array methods', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(() => proxy.splice(2)).toThrow( + new TypeError("'set' on proxy: trap returned falsish for property 'length'") + ) + + expect(obj).toEqual(objOriginal) + }) + + test('should not proxy primitives', () => { + expect(readonlyProxy(true)).toEqual(true) + expect(readonlyProxy(42)).toEqual(42) + expect(readonlyProxy('foo')).toEqual('foo') + expect(readonlyProxy(undefined)).toEqual(undefined) + }) +}) + +function createNestedObject() { + return [ + { id: 1, data: { value: 42 } }, + { id: 2, data: { value: 48 } } + ] +} diff --git a/src/lib/utils/readonlyProxy.ts b/src/lib/utils/readonlyProxy.ts new file mode 100644 index 00000000..1995028e --- /dev/null +++ b/src/lib/utils/readonlyProxy.ts @@ -0,0 +1,32 @@ +/** + * Create a readonly proxy around an object or array. + * + * Will throw an error when trying to mutate the object or array + * + * Inspired by: https://github.com/kourge/readonly-proxy/ + */ +export function readonlyProxy(target: unknown): unknown { + if (!isObject(target)) { + return target + } + + return new Proxy(target, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver) + + return readonlyProxy(value) + }, + + set() { + return false + }, + + deleteProperty() { + return false + } + }) +} + +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null +}