- 后端
【免费下载链接】loro
Make your JSON data collaborative and version-controlled with CRDTs
导读
本文围绕 Loro(CRDT 数据协同引擎)对 MovableList(可移动列表)中非法Move/Set操作的导入校验展开。在收到来自其他对端(外部输入)的更新时,指向未知元素、其他列表元素、超出操作因果历史范围,或(在浅快照文档上)指向浅根之前已删除元素的Move/Set,现在会返回DecodeError并使整个导入原子回滚,文档保持原状且可继续使用;而对已删除元素的移动/设置则按并发操作的语义被接受。读完本文,你将掌握 Loro 在导入路径上分层校验恶意/损坏操作的设计思路、底层实现(源码位置与调用链),以及 Rust 与 JS/WASM 两侧的测试验证方式。
背景:不合法操作如何让文档崩溃
MovableList 的Move { from, to, elem_id }与Set { elem_id, value }操作由其他对端产生,属于不可信的外部输入。在引入校验之前,若干畸形操作会在持有文档锁的状态下触发unwrap()panic:
MovableListState::apply_diff_and_convert中的pos.unwrap()/value_id.unwrap()MovableListHistoryCache::last_pos中的idlp_to_id(..).unwrap()MovableListDiffCalculator::calculate_diff中的last_value(..).unwrap()- 越界列表 delta 的
convert_index(..).unwrap() - diff tracker 的 B 树在巨型位置上越界
由于 panic 发生在LoroMutex锁内,会毒化(poison)锁;展开(unwind)过程中析构函数再次触碰被毒化的锁时,进程直接中止。在 WASM 环境下,panic 表现为不可恢复的 trap(RuntimeError: unreachable)。相关设计与讨论详见 context/movable-list-op-validation.md。
一、被拒绝(Err):无意义的操作
Loro 将"无意义"的操作分成三类,分别在解码期、导入期与状态校验期拦截。
1. 任何真实序列都无法到达的位置(解码期拦截)
InnerListOp::check_positions(crates/loro-internal/src/container/list/list_op.rs)在二进制(outdated_encode_reordered::decode_op)与 JSON(json_schema::decode_op)操作解码时即运行。它会拒绝任何达到或超过UNKNOWN_SPAN_LEN - 1的位置。UNKNOWN_SPAN_LEN = u32::MAX / 4(约 1,073,741,823),是 diff tracker 为未重放历史准备的占位 span 长度(crates/loro-internal/src/container/richtext/tracker.rs)。此前,位置超过该值会在validate_diff有机会运行之前就在 tracker 内部 panic。
该检查不需要历史数据,因此覆盖所有导入路径,包括分离(detached)导入。更重要的是,它是一个硬性文档级限制而非仅导入检查:decode_op同样是文档解析自己存储的 change block 与 snapshot block 的入口(block_encode.rs),因此 ≥ 1,073,741,822 的序列位置在那里也会被拒绝。这符合设计意图:
- 快照与更新块同样是外部输入;
- 约 10.7 亿项(≥1GB 文本)的序列任何对端都无法 diff 或导入(此前 tracker 会先 panic);
- 为"自有"块跳过检查需要把信任标志贯穿块解码全程,得不偿失。
2. 未知元素或超出因果历史的元素(导入期拦截)
OpLog::validate_movable_list_elem_refs_in_import_scope(crates/loro-internal/src/oplog.rs)逐一检查每个Move/Set:elem_id必须是同一容器内、位于该操作因果历史中的Insert操作,否则返回LoroError::DecodeError。实现要点:
- 元素引用由
OpLog::insert_new_change记录进打开的ImportRollback,因此覆盖直接导入的变更、导入解锁的 pending 变更,以及import_batch的每一个 blob;校验器只读记录列表,不会重新从 change store 读导入范围。 OpLog::resolve_movable_list_elem(crates/loro-internal/src/oplog.rs)通过一次ChangeStore::get_change_by_lamport_lte查找定位元素;该查找同时扫描已解析块与仅 KV 的块,因此 miss 意味着该 lamport 不在已存历史中。- 元素解析结果不依赖具体操作,因此每个
elem_id只解析一次并缓存(moves/sets 会反复命中同一元素),避免每操作克隆/插入版本向量(对比AppDag::get_vv,改用ensure_vv_for读取缓存的开头版本)。 - 因果性检查(
is_visible_movable_list_elem,crates/loro-internal/src/oplog.rs):同一 DAG 节点中更早的操作来自同一对端,节点起始版本已覆盖其他对端的因果部分;仅当elem_id.lamport >= op_lamport或元素非本容器插入时判定不可见。 - 浅快照文档上 miss 时回退到浅根状态(
ContainerHistoryCache::shallow_root_has_movable_list_elem):根之后的操作只能看到根处仍存活的根前元素。
调用点有三处(crates/loro-internal/src/loro.rs 附近的import_changes_and_apply_delta_to_state_if_needed):附加(attached)分支(启用rollback_enabled时);分离(detached)分支(preflight 请求作用域且无批处理作用域打开时自行打开);以及BatchImportGuard::finish——在收尾 checkout 前对整个批次校验一次,失败即整批回滚(见 context/import-batch-atomicity.md)。
3. 逃过导入的越界from/to(状态校验期拦截)
MovableListState::validate_diff在操作索引空间(计入已删除项)对列表 delta 做边界检查,与ListState::validate_diff一致:
- 该检查需要状态,因此分离导入无法运行它(见"已知缺口");
- 它检查的是合成后的 delta,因此被同一导入中后续操作抵消的越界 move(如
move to: 9后跟delete pos: 9)会被接受——所有路径对其结果一致; - 文本有等价检查:
RichtextState::validate_diff在实体索引空间边界检查文本 delta,此前超过文本末尾的 insert/delete/mark 会在insert_elem_at_entity_index中持锁 panic(loro-dev/loro#1160)。
为什么每个此类导入都需要回滚作用域
ImportChangesPreflight与PendingChanges::has_state_apply_rollback_ops会为 List、MovableList、Text、Tree 操作设置needs_state_apply_rollback(oplog::state_apply_can_reject)。没有该作用域的导入若被validate_diff拒绝其 diff 会 panic,因此validate_diff可能失败的容器类型必须列入其中:
- 作用域开销极小:每次导入仅若干小克隆与日志条目,单操作 Text 导入约 +0.3 µs(20k 次导入 ~44→50 ms,loro-dev/loro#1160);大导入无可测量变慢。
- preflight 会检查每一个新变更的操作,包括 deps 尚不在 DAG 中的变更(其 deps 可能是同一导入中更早的变更,会在导入过程中解锁它们)。
- 此前它会在查看操作前跳过这类变更,使
[C1: 仅 Map 变更, C2: 依赖 C1 的伪造操作]同时跳过回滚作用域与校验,跨容器 move 被静默接受、把同一元素放进两个列表(见测试cross_container_move_is_not_applied_to_two_lists)。
二、被接受(CRDT 语义):对已删除元素的 move/set
因果上位于其元素删除之后的Move/Set无法来自公开 API,但它仍然被接受,含义与对已删除元素的并发move/set 相同:move 创造了新位置,元素以最后的值复活;set 改变了一个无人能见的值。原因有二:
- 对伪造者无新增价值:伪造者可声明删除之前的 deps,通过合法并发 move 获得同样效果;
- 一致性拒绝代价不可承受:checkout 模式重放(
checkout、import_batch的收尾重挂、并发导入、detached →attach)需要知道元素在操作版本处是否存活,这要求从列表项到覆盖其删除的索引;若只有线性路径拒绝,import与import_batch对同一字节流的结果会不一致。
唯一无法直接应用该语义的路径是前向快速路径(DiffMode::Linear/ImportGreaterUpdates):那里 movable-list diff 只携带操作触及的字段,其余由DocState中的元素补全。检测与回退:
MovableListState::references_absent_elem/DocState::needs_checkout_diff检测 delta 是否移动/设置了状态中缺失的元素;recalc_in_checkout_mode_if_needed(crates/loro-internal/src/loro.rs)用DiffCalculator::new(true)(Persist,恒为 Checkout 模式)重算该导入的 diff,等价于完整重放;- 诚实的导入永远不会触发它;
validate_diff仍会对这类 delta 返回Err作为兜底。
三、测试验证:Rust 与 JS/WASM 双端
Rust 端
crates/loro/tests/movable_list_invalid_ops.rs 构造伪造 JSON 更新并通过import_json_updates导入,断言:
- 拒绝后文档不变且可用:
Snapshot::assert_unchanged校验深值与 oplog 版本向量;assert_usable验证仍可本地编辑、仍可与其他对端正常同步; - 每条导入路径结果一致:
assert_same_result_on_every_import_path在普通导入、二进制更新、import_batch、快照加载导入、detached 导入 +attach之间交叉验证,并与一次性全量重放的文档对比深值与版本向量; - 覆盖的非法样例:未知元素(
L99@0)、无操作的 peer、指向删除操作而非元素的 lamport(L3@0)、跨容器元素(L4@0)、因果历史之外的元素(L3@1)、越界from/to、巨型位置(u32::MAX/4及以上,包括 List/Text 的巨型 insert/delete)、同一导入内依赖(forged_after_map_change,单/双 peer 两种变体); - 合法场景:对已删除元素 move/set 与并发删除+移动的复活语义(
concurrent_delete_and_move_resurrects_moved_element)、浅快照上仍存活元素的 move/set、快照加载文档上旧元素的导入、反向删除(len: -1)仍被接受。
另见 crates/loro-internal/src/tests/import_atomicity.rs 的二进制与import_batch用例、crates/loro-internal/src/oplog/change_store.rs 的ChangeStore回滚与 KV 查找测试。
JS/WASM 端
crates/loro-wasm/tests/movable_list_invalid_ops.test.ts 验证 WASM 行为:错误必须可读(/Decode error/)且绝不出现unreachabletrap;文档 JSON 与 oplog 版本不变且仍可编辑同步。同时覆盖:伪造操作依赖同一导入内变更、巨型位置、对已删除元素的 move 应用、快照加载文档导入旧元素,以及getChangeAtLamport在快照加载后能找到存储在 KV 块中的变更(该用例直接暴露了下文的 change-store 缺陷)。
四、本次工作暴露的 ChangeStore 陷阱
decode_block_range错读一个版本 varint
encode_block写出的 postcardEncodedBlock无版本前缀,但decode_block_range仍按带版本前缀读取,导致每个字段错位一位。历史:3d2d9d9c(2024-09,"refactor: optimize block encoder")移除了块的version字段及其编码器与完整解码器,却遗漏了decode_block_range。所有loro-crdt@1.0.0*版本都包含该提交,因此任何 1.x 构建都从未写过带版本前缀的块,修复不会导致存量数据不可读。其后果包括:非 0 counter 开头的块被跳过;0@P块把 lamport 长度当成了 lamport 起点;仅 KV 块上的 lamport 查找错误(快照加载后的get_change_with_lamport_lte/ JSgetChangeAtLamport在 main 上同样损坏);元素校验器也受影响。既有测试未发现是因为它们先解析了每个块。
回滚不得把旧块留在下一次插入之前
insert_change_inner会把变更合并进其前一个缓存块。失败序列:一次导入创建对端最新块而未加载更旧块;作用域内的一次读取(如校验器的 lamport 查找)缓存了更旧的 KV 块;回滚移除最新块;下一次同变更插入便以 "counter should be continuous" panic。因此ChangeStore::rollback_import会驱逐回滚触及的每个对端已 flush 的块,按需从 KV 重载。此外该查找解析时注册的容器链接会被 arena 回滚丢弃,回滚因此会丢弃自导入开始以来解析块的变更(见 context/arena-parent-links.md 的 import rollback 小节)。
廉价回滚记录
ChangeStoreRollback为每个导入追加过的未 flush 的 scope 前块保存一个BlockShape:变更数、末变更的操作数与最后一个操作。回滚截断回该形状即可,因为导入只追加。flush 的块无需记录:其 KV 副本即 scope 前版本。此前的记录是整个块的Arc,使 scope 下每次导入的下一次追加都要复制块的变更,小 movable-list 导入慢约 40%,而 List/Tree 导入在 main 上已为此付出代价(现在快约 45%)。
五、已知缺口(未在本次修复中解决)
from未指向其元素的Move被接受:tracker 会移除from处的任意列表项,导致另一元素消失。结果在所有路径一致,但不匹配任何诚实操作。- 显式分离的文档导入仅被状态校验拒绝的操作(越界列表插入、越界 movable-list move),在
attach()/checkout_to_latest上会 panic(它们返回()并expectcheckout)。影响所有容器类型,且早于本次变更。 FastSnapshot内的 oplog 未逐操作校验(那需解码每个块);伪造快照仍可能在后续 checkout 触达 diff 计算器的 unwraps。- 伪造变更若作为 pending 停放,解锁它的后续导入会失败并被回滚,与列表越界错误行为一致。
总结
Loro 对 MovableList 非法Move/Set的导入防御是分层设计:解码期用check_positions拒绝不可达的巨型位置(覆盖所有路径,含 detached 导入);导入期用validate_movable_list_elem_refs_in_import_scope校验元素引用(覆盖整批导入与批量回滚);状态校验期用MovableListState::validate_diff检查合成 delta 的越界索引。三类检查共同把此前会毒化锁并导致进程中止/WASM trap 的输入,转化为可读的DecodeError与文档原状回滚;而对已删除元素的 move/set 按并发 CRDT 语义接受,保证了所有导入路径(含快速路径回退到 checkout 模式重算)的结果一致性。伴随修复还校正了 change-store 的块解码与回滚记录,并由 Rust 与 TS 双端测试锁定行为,为其他容器的外部输入校验提供了可复用的参考范式。
- 后端
【免费下载链接】loro
Make your JSON data collaborative and version-controlled with CRDTs
相关推荐
Type Challenges 进阶:实现 GetRequired\<T\>,从对象类型中提取所有必需属性
Type Challenges 进阶:实现 GetRequired\<T\ ,从对象类型中提取所有必需属性 导读 GetRequired<T 是 type ch
后端从崩溃到回滚:Miniforge版本管理的完整安全操作指南
从崩溃到回滚:Miniforge版本管理的完整安全操作指南 引言 在软件开发和数据科学工作中,环境配置和版本管理是至关重要的环节。Miniforge作为一个轻量
开发工具包管理器HsMod终极指南:55个功能全面改造你的炉石传说游戏体验
HsMod终极指南:55个功能全面改造你的炉石传说游戏体验 HsMod是一款基于BepInEx插件框架的开源炉石传说模改工具,它通过55+实用功能实现游戏体验的
游戏开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考