diff --git a/src/lib/canvas-runtime/import-identities.ts b/src/lib/canvas-runtime/import-identities.ts index 74ad770..c91db09 100644 --- a/src/lib/canvas-runtime/import-identities.ts +++ b/src/lib/canvas-runtime/import-identities.ts @@ -8,10 +8,14 @@ import {fault} from '../canvas-core/values.ts'; /** 源记录/依赖键到目标ID的只读字典,element/canvas前缀由调用者约定。 */ type Ids=Readonly>; /** One runner's last immutable ID index. Copies are deferred until allocation. */ -/** 返回带clear方法的捕获工厂,每次为一个源记录的准备提供reserve/has/finish私有上下文。 */ +/** 一个 runner 的捕获工厂,缓存最近一张可复用表及其 used Set;每次 capture 创建独立分配上下文。 + * 初始表的值域/空 ID/已有重复映射未在此完整校验,Set 只用于拦截后续 createId 与已知 ID 碰撞。 + * 不查项目文档、墓碑、其它 runner 或服务身份空间;稳定映射须由 job 校验与发布层共同保证。 */ export function createImportIdentities(){ let cached:{ids:Ids;used:Set}|undefined; - /** 相同已冻结纯字符串属性表可复用used索引;其它输入先复制为无原型字典。 */ + /** 同引用命中缓存时直接复用;否则仅将冻结且描述符为字符串数据值的输入视为可复用。 + * 这是性能准入,不是完整 schema/普通对象证明;其余输入以 Object.assign 捕获到无原型字典,getter 会在此求值。 + * 首次分配前共享基表/used;写时再分离,避免每条只查既有键的记录重扫整张映射。 */ const capture=(input:Ids,createId:()=>Id)=>{ let identity=cached?.ids===input?cached:undefined; if(!identity){ @@ -30,10 +34,12 @@ export function createImportIdentities(){ }, /** 判断目标ID是否已被本映射使用,不声称该ID已加入文档或已同步。 */ has:(id:Id)=>used.has(id), - /** 返回本次分配后的只读映射;没有新分配时保留输入身份便于下一记录复用索引。 */ + /** owned 缓冲通过 dataRecord 再捕获不可变值,后续 reserve 不改变已经返回的结果。 + * 可复用输入且无分配时沿用原表;可变/getter 输入即使未分配也返回捕获表,每次 finish 可重新捕获。 + * 返回不代表映射已落盘;runner 须与导入进度/文档同次发布和持久化。 */ finish:():Ids=>owned?dataRecord(ids):ids, }); }; - /** clear只丢弃临时复用索引,不清除已持久化job的stableIds。 */ + /** clear 只丢工厂最近索引;现有 capture 上下文仍能使用,不清已发布/持久 job.stableIds,也不回收 ID。 */ return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}})); } diff --git a/src/lib/canvas-runtime/import-read-proof.ts b/src/lib/canvas-runtime/import-read-proof.ts index e6c571a..d8ddee8 100644 --- a/src/lib/canvas-runtime/import-read-proof.ts +++ b/src/lib/canvas-runtime/import-read-proof.ts @@ -15,7 +15,9 @@ const MAX_SOURCE_BYTES=64*1024*1024; /** version=1是块证明DTO版本,与source.integrityVersion=2的业务封存协议不是同一版本域。 */ export type ImportReadProof={readonly version:1;readonly fileId:string;readonly byteLength:number;readonly blockBytes:16384;readonly blocks:readonly {readonly index:number;readonly sha256:string}[]}; /** This binds only read blocks. It is not a whole-source digest or permission to send mutations. */ -/** 校验fileId/长度/固定块大小、严格递增的合法块索引及摘要;可表示只读过部分非连续块。 */ +/** 校验源身份/长度、固定块宽、严格递增的合法块索引、摘要格式及允许字段,再捕获冻结 DTO。 + * 可包含不连续的部分块,不要求完整前缀;这里不读 Blob 或验证摘要真实性,也不是整源 SHA。 + * 缺记录/非法 JSON/未知版本报解释器不可用,其它身份/结构错误报 invalid_import_read_proof。 */ export function validateImportReadProof(value:unknown,fileId:string,byteLength:number):ImportReadProof{ if(typeof fileId!=='string'||!fileId||!Number.isSafeInteger(byteLength)||byteLength<0||byteLength>MAX_SOURCE_BYTES)throw fault('invalid_import_read_proof'); const proof=value as ImportReadProof; @@ -29,7 +31,10 @@ export function validateImportReadProof(value:unknown,fileId:string,byteLength:n /** Bounded, seekable verified reads for parsers; checkpoints contain hashes, never byte buffers. * Restored evidence must be checked with verify() before a runner resumes business changes. */ -/** 创建可seek的验证reader;初始证明与源绑定,port.read成功后才把新块追加到本地证明。 */ +/** 建立 seek reader:hashes 保存期望摘要,resident 是按 Map 顺序淘汰的 LRU,pending 合并同块在途读取。 + * 初始 proof 仅结构校验即进入 hashes/getProof;没有 verified 标志,调用方须在恢复业务前主动 verify。 + * 最多 4 个活跃非空 range、4 个不同块的原生读取;驻留默认 4 块(最多 64),单 range 默认/最多 4MiB。 + * range 输出、在途块和 Blob 本身另占内存,residentLimit 不是整体内存硬上限;本实例不持久化证明。 */ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:ImportReadProof;maxResidentBlocks?:number;maxReadBytes?:number}&ImportScheduling){ const options=Object.freeze({...input}); const {bytes,fileId}=options,residentLimit=options.maxResidentBlocks??4,readLimit=options.maxReadBytes??4*1024*1024; @@ -40,13 +45,17 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp type Block={index:number;sha256:string;data:Uint8Array}; const resident=new Map(),pending=new Map>(),lifecycle=new AbortController(); let closed=false,activeReads=0,proof:ImportReadProof=initial; - /** 每次等待前后检查生命周期/取消;close是源关闭,调用者abort是停止此订阅采纳。 */ + /** 入口/成功采纳时检查 closed 与 signal,关闭优先报 import_source_closed,其次报 import_aborted。 + * 单个订阅取消只停止等待,不终止共享原生块读取;已拒绝的底层 Promise 仍可能保留自己的原始错误。 */ const check=(signal:AbortSignal)=>{if(closed)throw fault('import_source_closed');if(signal.aborted)throw fault('import_aborted');}; /** 单调用可取消等待共享块Promise,不强制取消可能正被其它range复用的只读底层读取。 */ function wait(task:Promise,signal:AbortSignal):Promise{ check(signal);return new Promise((resolve,reject)=>{const abort=()=>{try{check(signal);}catch(error){reject(error);}};signal.addEventListener('abort',abort,{once:true});task.then(value=>{signal.removeEventListener('abort',abort);try{check(signal);resolve(value);}catch(error){reject(error);}},error=>{signal.removeEventListener('abort',abort);reject(error);});}); } - /** 先命中LRU或共享pending,再读块并对旧摘要;新原图与已证明块不符即源改变。 */ + /** 命中 resident 时移动到 Map 尾部作为最近使用,命中 pending 时复用同一 Promise。 + * 缺块就读完整对齐 16KiB(最后块截 EOF)并计算 SHA,可能覆盖请求 range 之外的相邻字节。 + * 比对 hashes 中旧期望后放 resident,但不在此新增持久候选 proof;range 失败也可留下可复用缓存。 + * 缓存命中不重新读 Blob/算摘要,依赖 Blob 不可变合同;淘汰只丢字节,不丢已提交的期望摘要。 */ function block(index:number):Promise{ const cached=resident.get(index);if(cached){resident.delete(index);resident.set(index,cached);return Promise.resolve(cached);} const active=pending.get(index);if(active)return active; @@ -61,7 +70,10 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp })(); pending.set(index,task);void task.finally(()=>{if(pending.get(index)===task)pending.delete(index);}).catch(()=>{});return task; } - /** 精确range端口:只分配所需输出,逐块拼接并按软预算让出,最后成功才提交used块证明。 */ + /** 精确 range 端口:要求 end ≤ EOF,不自行截断;空范围在容量检查前返回,不读块/新增证明。 + * 非空范围分配独立输出并逐块拼接,每块向预算记 128 单位,最后检查取消与并发摘要一致性。 + * 只有整次范围成功才将 used 中新块加入 hashes/proof;取消/失败不提交本次部分块,也不回退其它成功读取的证明。 + * activeReads 随等待退出而归还,真实不可取消 I/O 仍占 pending 容量,不能用大量取消绕过底层上限。 */ const port:ImportByteReader=Object.freeze({byteLength:bytes.size,async read(start:number,end:number,signal:AbortSignal){ const combined=AbortSignal.any([signal,lifecycle.signal]);check(combined); if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||endbytes.size||end-start>readLimit)throw fault('invalid_import_byte_range'); @@ -81,9 +93,12 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp }finally{activeReads--;} }}); return Object.freeze({port,getProof:()=>proof, - /** 重开后验证所有已保存块;不扫描尚未读到的尾部,故不能代替完整SHA或已durable封存。 */ + /** 对调用开始时 hashes 中的索引逐块核对,不增加新证明块、不产生完整源摘要或自动持久化。 + * 重开实例缓存为空时实际重读旧块;同实例调用可命中 resident,不强制绕过缓存重新读物理字节。 + * 不核对尚未证明的尾部,也不锁住并发 range;是否已安全恢复、何时封存由引擎决定。 */ async verify(signal:AbortSignal){const combined=AbortSignal.any([signal,lifecycle.signal]),budget=createImportBudget(combined,options);check(combined);for(const index of [...hashes.keys()]){await wait(block(index),combined);await budget.step(128);}check(combined);}, - /** 幂等关闭,清驻留字节并取消等待者;已开始的只读Blob读取可能迟到,由closed检查拒绝采纳。 */ + /** 幂等关闭并清 resident/取消等待者,不清 hashes/proof,getProof 仍可读取最后不可变快照。 + * 不等待原生 Blob 读取退出;pending 由各 Promise finally 清理,迟到读取经 closed 检查拒绝进入缓存。 */ close(){if(closed)return;closed=true;resident.clear();lifecycle.abort();}, /** 只读诊断:驻留/活range/pending块/证明块分别计数,不当作导入完成百分比。 */ stats:()=>Object.freeze({residentBlocks:resident.size,activeReads,pendingBlocks:pending.size,proofBlocks:hashes.size,closed}), diff --git a/src/lib/canvas-runtime/import-verification.ts b/src/lib/canvas-runtime/import-verification.ts index 88b65f0..653e061 100644 --- a/src/lib/canvas-runtime/import-verification.ts +++ b/src/lib/canvas-runtime/import-verification.ts @@ -8,20 +8,28 @@ import {importNeedsSeal,validateImportJob,type ImportJob} from './imports.ts'; import {validateImportReadProof} from './import-read-proof.ts'; /** A derived durable-cut index, never an in-memory permission boolean. */ -/** 从给定job表派生fileId→SHA索引;调用者须传已可靠保存的cut,本函数不执行落盘。 */ +/** 从 integrity-v2、带 SHA 且无 integrityError 的 job 派生 fileId→SHA Map,不扫描字节或读取存储。 + * 调用方必须传已持久 cut;函数不证明其来源可靠,也不独立调用 validateImportJob 验摘要。 + * 返回实际可变 Map,若重复 fileId 后项覆盖前项;文件身份唯一性由外围约束保证。 */ export function importSeals(jobs:ReadonlyMap|undefined){ const seals=new Map();for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&job.source.sha256&&!job.source.integrityError)seals.set(job.source.fileId,job.source.sha256);return seals; } -/** integrity-v2任务需自身已完整验证且durable seals匹配;取消且零已提交前缀的任务可不阻塞。 */ +/** 整个 job 表的发送闸门:每个非豁免 integrity-v2 源都须无需 seal 且与 seals 中 SHA 相等。 + * 取消且 committedSourceKeys 为空者豁免;不按本次请求挑选 job,也不重验块证明/实际存储事务。 + * 其它 integrityVersion 在此跳过,是否支持/合法由 job 解释器先校验,不能把 true 当所有源都已验证。 */ export function importsMaySend(jobs:ReadonlyMap|undefined,seals:ReadonlyMap){ for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&(importNeedsSeal(job)||seals.get(job.source.fileId)!==job.source.sha256))return false;return true; } /** Only engine-owned publication paths may advance read evidence or source verification. */ -/** 发布前保护引擎拥有的readProof/SHA/执行进度,未持owner任务ID的路径不能伪造或删除它们。 */ +/** 比较前后 job 表保护 integrity-v2 证据,owner 是引擎传入的当前任务 ID,不是外部权限令牌。 + * 同一表引用直接返回,依赖不可变状态合同;v2 job 不得删除或降级,即使 owner 匹配也不例外。 + * 每个 next v2 job 验结构且要求 proof 索引为从 0 开始的连续前缀,比通用 seek reader 更严格。 + * owner 可推进证据/执行事实,仍不能改 fileId/长度/已有 SHA 或丢改旧块;此处不重算字节或检查持久水位。 */ export function assertImportEvidence(previous:ReadonlyMap|undefined,next:ReadonlyMap|undefined,owner:Id|undefined){ if(previous===next)return; for(const[id,job]of previous??[])if(job.source?.integrityVersion===2&&!next?.has(id))throw fault('import_integrity_owned',[id]); - /** 对比执行事实时剔除generation和源retirement生命周期字段;其它进度/身份仍要求合法owner。 */ + /** 比较执行事实时仅剔除 job.generation 和当前 source.retirement;previousFiles 等内容仍参与比较。 + * 允许普通保留流程调整这些排除字段,不在这里证明 generation/退休时钟单调,交其它发布校验处理。 */ const execution=(job:ImportJob|undefined)=>{if(!job)return job;const{generation:_,source,...body}=job;if(!source)return body;const{retirement:__,...identity}=source;return {...body,source:identity};}; for(const[id,job]of next??[]){const source=job.source,old=previous?.get(id)?.source;if(source?.integrityVersion!==2){if(old?.integrityVersion===2)throw fault('import_integrity_owned',[id]);continue;} validateImportJob(job);const proof=validateImportReadProof(source.readProof,source.fileId,source.byteLength);