From 697716ee894a751897bf7d9c65589e7e65833d01 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 10 Nov 2026 15:08:19 +0800 Subject: [PATCH] Reuse immutable JSON validation proofs without weakening publication checks --- headless/README.md | 7 +++++++ src/lib/canvas-core/values.ts | 32 ++++++++++++++++++++++++++++-- tests/headless/valid-json.test.mjs | 28 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tests/headless/valid-json.test.mjs diff --git a/headless/README.md b/headless/README.md index 7201459..efdf10f 100644 --- a/headless/README.md +++ b/headless/README.md @@ -855,3 +855,10 @@ step. The existing recursive codec still checks nested mutability and depth, so mutable values and external Maps are re-read. This is an encoding cache, not a second state table; recovery JSON order, formats and journal checks remain the same. Replacing a table gets a new entry view and leaves prior images unchanged. + +JSON validation can reuse a successful proof for transitively immutable data +properties. A WeakMap stores subtree height so a cached value still obeys the +caller's depth bound. Shallow freezing, accessors, mutable descendants and +unproven array methods keep the original validation path; optional reflection +failures only disable proof reuse. Component/schema validators are still called, +and document ownership, endpoints and topology are still checked on publication. diff --git a/src/lib/canvas-core/values.ts b/src/lib/canvas-core/values.ts index 480dc91..d26a371 100644 --- a/src/lib/canvas-core/values.ts +++ b/src/lib/canvas-core/values.ts @@ -46,10 +46,38 @@ export function readSet(input: Iterable): ReadonlyValueSet { }; return Object.freeze(view); } +// Successful proofs only: frozen data properties whose children have their own +// immutable proof. Neither shallow freeze nor a user-defined Array.every qualifies. +const jsonHeights=new WeakMap(),nativeEvery=Array.prototype.every; +function jsonShape(value:object,array:boolean,every:unknown){ + try{const own=array?Object.getOwnPropertyDescriptor(value,'every'):undefined;return Array.prototype.every===nativeEvery&&(!array||Object.getPrototypeOf(value)===Array.prototype&&!!own&&Object.hasOwn(own,'value')&&own.value===nativeEvery&&every===nativeEvery);} + catch{return false;} +} +function jsonHeight(value:unknown):number|undefined{ + if(value===null||typeof value==='string'||typeof value==='boolean'||typeof value==='number'&&Number.isFinite(value))return 0; + return value&&typeof value==='object'?jsonHeights.get(value):undefined; +} export function validJson(value: unknown, depth = 0): value is Json { if (depth > 64) return false; if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; if (typeof value === 'number') return Number.isFinite(value); - if (Array.isArray(value)) return value.every(item => validJson(item, depth + 1)); - return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype && Object.values(value).every(child => validJson(child, depth + 1)); + if(!value||typeof value!=='object')return false; + const array=Array.isArray(value); + if(!array&&Object.getPrototypeOf(value)!==Object.prototype)return false; + const every=array?value.every:undefined,ordinary=jsonShape(value,array,every); + const proven=ordinary?jsonHeights.get(value):undefined; + if(proven!==undefined)return !(depth+proven>64); + const valid=array?Reflect.apply(every!,value,[(item:unknown)=>validJson(item,depth+1)]):Object.values(value).every(child=>validJson(child,depth+1)); + if(valid&&ordinary)try{if(Object.isFrozen(value)){ + let height=0,immutable=true; + const keys=array?undefined:Object.keys(value); + for(let index=0;index<(array?value.length:keys!.length);index++){ + const key=array?String(index):keys![index]; + const descriptor=Object.getOwnPropertyDescriptor(value,key),child=descriptor&&Object.hasOwn(descriptor,'value')?jsonHeight(descriptor.value):undefined; + if(child===undefined){immutable=false;break;} + height=Math.max(height,child+1); + } + if(immutable)jsonHeights.set(value,height); + }}catch{/* Optional proof inspection cannot reject an otherwise valid value. */} + return valid; } diff --git a/tests/headless/valid-json.test.mjs b/tests/headless/valid-json.test.mjs new file mode 100644 index 0000000..4f28c3e --- /dev/null +++ b/tests/headless/valid-json.test.mjs @@ -0,0 +1,28 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {validJson,freeze} from '../../src/lib/canvas-core/values.ts'; +test('transitively immutable JSON can reuse its proof without enumerating the same object again',()=>{ + const value=freeze({data:{text:'safe'},meta:{number:1,flag:true,empty:null}});assert.equal(validJson(value),true);const values=Object.values;try{Object.values=object=>{if(object===value)throw Error('immutable root enumerated twice');return values(object);};assert.equal(validJson(value),true);}finally{Object.values=values;} +}); +test('a cached subtree still observes the validation depth limit at each new position',()=>{ + const leaf=freeze({value:1});assert.equal(validJson(leaf),true);const nest=count=>{let value=leaf;while(count--)value=Object.freeze({child:value});return value;};assert.equal(validJson(nest(63)),true);assert.equal(validJson(nest(64)),false);assert.equal(validJson(leaf,64),false);assert.equal(validJson(Object.freeze({}),64),true); +}); +test('shallow freeze and getters do not hide mutable values or bypass subsequent validation',()=>{ + const child={value:1},parent=Object.freeze({child});assert.equal(validJson(parent),true);child.value=undefined;assert.equal(validJson(parent),false);child.value=2;assert.equal(validJson(parent),true); + let field=1,calls=0;const getter=Object.freeze({get value(){calls++;return field;}});assert.equal(validJson(getter),true);field=Infinity;assert.equal(validJson(getter),false);assert.equal(calls,2); +}); +test('sparse/inherited array entries and overridden every methods keep their observable behavior',()=>{ + const prototype=Object.create(Array.prototype);prototype[0]=1;const sparse=new Array(1);Object.setPrototypeOf(sparse,prototype);Object.freeze(sparse);assert.equal(validJson(sparse),true);prototype[0]=undefined;assert.equal(validJson(sparse),false); + let accepted=true,calls=0;const custom=[1];Object.defineProperty(custom,'every',{value(){calls++;return accepted;}});Object.freeze(custom);assert.equal(validJson(custom),true);accepted=false;assert.equal(validJson(custom),false);assert.equal(calls,2); + let native=true,methodReads=0;const getterArray=[1];Object.defineProperty(getterArray,'every',{get(){methodReads++;return native?Array.prototype.every:()=>false;}});Object.freeze(getterArray);const parent=Object.freeze({getterArray});assert.equal(validJson(parent),true);native=false;assert.equal(validJson(parent),false);assert.equal(methodReads,2); + const hole=Object.freeze(new Array(1));assert.equal(validJson(hole),true);assert.equal(validJson(hole,64),true); +}); +test('ordinary invalid JSON, cycles and unsupported prototypes remain invalid',()=>{ + const cycle={};cycle.self=cycle;for(const value of [undefined,NaN,Infinity,()=>{},Symbol('x'),1n,{value:undefined},[undefined],new Date(),Object.create(null),cycle])assert.equal(validJson(value),false);assert.equal(validJson({text:'中文',number:0,empty:[]}),true); +}); + +test('proxy reflection failures and mutable inherited array methods cannot produce an immutable proof',()=>{ + const target={value:1},proxy=new Proxy(target,{isExtensible(){throw Error('optional inspection denied');}});assert.equal(validJson(proxy),true);target.value=undefined;assert.equal(validJson(proxy),false); + const arrayTarget=Object.freeze([1]);let accepts=true;const arrayProxy=new Proxy(arrayTarget,{get(target,key,receiver){if(key==='every')return accepts?Array.prototype.every:()=>false;return Reflect.get(target,key,receiver);}}),parent=Object.freeze({arrayProxy});assert.equal(validJson(parent),true);accepts=false;assert.equal(validJson(parent),false); + const other=new Proxy([1],{getPrototypeOf(){throw Error('no shape proof');}});assert.equal(validJson(other),true); +});