Reuse immutable JSON validation proofs without weakening publication checks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -46,10 +46,38 @@ export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
|
||||
};
|
||||
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<object,number>(),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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user