import type { Json } from '../canvas-core/types.ts'; import { fault, freeze, validJson } from '../canvas-core/values.ts'; import type { RecoveryImage } from './storage.ts'; type Encoded = {value:Json|undefined;height:number;reusable:boolean}; const immutable=new WeakMap(),sealed=new WeakSet(); /** JSON value semantics, with reuse only for transitively frozen plain data properties. */ export function encodeRecoveryValue(input:unknown):Json { const visiting=new Set(); function visit(value:unknown,depth:number,key:string):Encoded { if(value===null||typeof value==='string'||typeof value==='boolean'){if(depth>64)throw fault('invalid_recovery_payload');return{value,height:0,reusable:true};} if(typeof value==='number'){if(depth>64)throw fault('invalid_recovery_payload');return{value:Number.isFinite(value)?(Object.is(value,-0)?0:value):null,height:0,reusable:true};} if(value===undefined||typeof value==='symbol')return{value:undefined,height:0,reusable:true}; const fallback=()=>{ // Preserve the previous JSON codec for toJSON, boxed values and custom prototypes. // These can depend on hidden mutable state and are deliberately never cached. const text=JSON.stringify({[key]:value}),encoded=JSON.parse(text)[key] as Json|undefined; if(encoded!==undefined&&!validJson(encoded,depth))throw fault('invalid_recovery_payload'); return{value:freeze(encoded),height:0,reusable:false}; }; if(typeof value==='bigint'||typeof value==='function')return fallback(); if(typeof value!=='object')throw fault('invalid_recovery_payload'); const object=value as object,cached=immutable.get(object); if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;} const prototype=Object.getPrototypeOf(object); // A callable toJSON getter must also be evaluated afresh on every encode. if(typeof (object as {toJSON?:unknown}).toJSON==='function'||(Array.isArray(object)?prototype!==Array.prototype&&prototype!==null:prototype!==Object.prototype&&prototype!==null))return fallback(); if(depth>64)throw fault('invalid_recovery_payload'); if(visiting.has(object))throw fault('invalid_recovery_payload');visiting.add(object); const toJSON=Object.getOwnPropertyDescriptor(object,'toJSON'); let reusable=Object.isFrozen(object)&&(!toJSON||Object.hasOwn(toJSON,'value')),height=0; try { if(Array.isArray(object)){ const result:Json[]=[]; for(let index=0;index64)throw fault('invalid_recovery_payload');result.push(child.value??null);height=Math.max(height,1+child.height);reusable&&=child.reusable; } const encoded={value:Object.freeze(result) as unknown as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded; } const result:Record={}; for(const name of Object.keys(object)){ const descriptor=Object.getOwnPropertyDescriptor(object,name);if(!descriptor||!Object.hasOwn(descriptor,'value'))reusable=false; const child=visit((object as Record)[name],depth+1,name);reusable&&=child.reusable; if(child.value!==undefined){Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height);} } const encoded={value:Object.freeze(result) as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded; }finally{visiting.delete(object);} } const result=visit(input,0,'');if(result.value===undefined)throw fault('invalid_recovery_payload');return result.value; } /** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ export function sealRecoveryImage(formatVersion:1|2|3|4,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { if(![1,2,3,4].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image; } export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}