Skip to content

Commit 5c7552d

Browse files
zxch3nclaude
andauthored
fix: reject malformed movable list move/set ops on import instead of aborting (#1125)
* fix: reject malformed movable list move/set ops on import instead of aborting A movable-list Move/Set whose elem_id pointed at a deleted, unknown, or out-of-history element, or a move past the end of the list, panicked while the doc held its locks. The lock was poisoned and the process aborted. - Validate Move/Set element references after oplog insertion, inside the import rollback scope. Covers attached, detached, legacy, and import_batch paths, and shallow roots. - Bounds-check movable-list diffs in MovableListState::validate_diff, and enable import rollback for MovableList ops. - Apply a move/set of a deleted element with concurrent-op semantics on every path. The linear fast path falls back to a Checkout-mode diff. Rationale and remaining gaps: context/movable-list-op-validation.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: address review of movable list import validation (B1-B4) - decode_block_range read a version varint that the block encoding does not have, shifting every field. KV-only blocks were misread, so get_change_with_lamport_lte / getChangeAtLamport returned wrong results after a snapshot load (also on main), and the element validator rejected honest imports. idlp_to_id None now only falls back to the shallow-root state for trimmed history on shallow docs. - ChangeStore::rollback_import evicts flushed blocks of touched peers, so a block cached during the scope cannot sit before the next insert with a counter gap ("counter should be continuous" abort). - The import preflight inspects the ops of every new change, including ones whose deps arrive in the same import, so validation and rollback can no longer be bypassed. - Sequence op positions past the diff tracker's placeholder span are rejected when decoding binary and JSON ops (all sequence containers). - Drop the unreachable validation in the legacy import path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf: make import rollback records cheap and validate recorded refs - ChangeStoreRollback kept an Arc of every pre-scope block an import appended to, so the append copied the block's changes (copy-on-write) on every import under a rollback scope. It now keeps the block's shape (change count, last change's op count and last op) and truncates back; flushed blocks are simply evicted and reload from KV. This also speeds up List/Tree imports that already ran under a rollback scope on main. - insert_new_change records movable-list Move/Set element refs in the open rollback scope; the validator checks that list instead of re-reading the imported range from the change store. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf: resolve a movable list element with one change lookup The validator looked the element's change up by lamport and then looked it up again by id to read the op; read the op from the first result. The import rollback also kept a second copy of the pre-import version vector; share the change store's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: WASM regressions for malformed movable list ops and snapshot lamport lookups Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: movable list validation context and changeset for the review fixes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf: resolve each movable list element once per validation pass What an element id resolves to (insert op, container) does not depend on the op that targets it, so cache it per pass. The causal check reads the cached start version of the op's DAG node instead of cloning a version vector and inserting into it for every op. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: perf numbers, getChangeAtLamport fix and position limit in changeset and context Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: widen the small-import perf range in the changeset Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6e294c8 commit 5c7552d

23 files changed

Lines changed: 1849 additions & 44 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"loro-crdt": patch
3+
---
4+
5+
Importing a malformed movable-list `move` or `set` op no longer aborts the process. These ops used to panic inside the document locks, poisoning them, and the process then aborted during unwind. Now:
6+
7+
- A `move`/`set` whose element id is not an element of the same list in the op's causal history, or a `move` whose index is past the end of the list, makes `import`, `importJsonUpdates` and `importBatch` return an error. Nothing from that import is applied and the document stays usable. This also holds when the op depends on another change in the same import, which used to skip validation.
8+
- Insert, delete and move positions of 1,073,741,822 (about 2^30) or more in a List, MovableList or Text op are rejected when the op is decoded, instead of panicking during import. This is a hard limit on sequence length: it also applies when a document reads its own stored history or a snapshot. No peer could import such a document before either.
9+
- A `move`/`set` that targets an element deleted earlier in its own history is applied the same way a concurrent move/set of a deleted element is: the move brings the element back, and the set is not visible. Every import path, and a replay of the full history, gives the same result.
10+
11+
Also fixed:
12+
13+
- `getChangeAtLamport` returned `undefined` or the wrong change for changes that were loaded from a snapshot and not yet read. The change store misread the lamport range of stored blocks, a leftover from the block format change in 1.0. On a snapshot-loaded peer with many blocks, 42 of 669 sampled lamports returned `undefined` and 625 returned the wrong change; all 669 are now correct.
14+
- A rolled-back import could leave a cached change block behind that made the next import of the same changes panic with "counter should be continuous".
15+
- Imports of List and Tree ops no longer copy the peer's last change block on every import to keep a rollback copy. Many small List or Tree imports are about 30% faster.
16+
17+
Performance: movable-list imports now check every `move`/`set` against the document's history. In release benchmarks that makes them about 2–8% slower for many small imports and 0–4% slower for a 120k-op import where about 70% of the ops are moves and sets.

‎AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec.
2424
[context/mergeable-containers.md](context/mergeable-containers.md).
2525
- `import_batch` force-detach, batch-wide rollback scope, and the never-exit-detached
2626
invariant: [context/import-batch-atomicity.md](context/import-batch-atomicity.md).
27+
- Movable-list `Move`/`Set` import validation, and why a move of a deleted element
28+
is applied rather than rejected:
29+
[context/movable-list-op-validation.md](context/movable-list-op-validation.md).
2730
- Shallow-snapshot dead-style redaction (null-only, both-expand exclusion,
2831
root-whitelisted latest state):
2932
[context/shallow-snapshot-style-redaction.md](context/shallow-snapshot-style-redaction.md).

‎context/import-batch-atomicity.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ is still at its pre-batch version and the `OpLog` is the only thing that moved.
3232
the `OpLog` therefore makes the two agree again, which is what lets the doc stay
3333
attached. `import_batch` opens `OpLog::begin_import_rollback` before the loop and
3434
`BatchImportGuard::finish` either commits it or rolls the whole batch back and returns
35-
the state-apply error.
35+
the state-apply error. Before that checkout, `finish` also validates the movable-list element references
36+
of every blob in the batch ([movable-list-op-validation.md](movable-list-op-validation.md)).
37+
A failure there rolls the batch back the same way.
3638

3739
Consequences to keep in mind:
3840

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Movable List `Move`/`Set` Validation on Import
2+
3+
Verified against code 2026-09-28 (merged with main `6e294c87`).
4+
5+
Imported movable-list `Move { from, to, elem_id }` and `Set { elem_id, value }` ops
6+
come from other peers, so they are external input. Several shapes of them used
7+
to panic while the doc held its locks (`pos.unwrap()`/`value_id.unwrap()` in
8+
`MovableListState::apply_diff_and_convert`, `idlp_to_id(..).unwrap()` in
9+
`MovableListHistoryCache::last_pos`, `last_value(..).unwrap()` in
10+
`MovableListDiffCalculator::calculate_diff`, `convert_index(..).unwrap()` for
11+
an overrunning list delta, and the diff tracker's B-tree for huge positions).
12+
The panic poisoned a `LoroMutex`, and the process aborted when a destructor hit
13+
the poisoned lock during unwind.
14+
15+
Tests:
16+
- `crates/loro/tests/movable_list_invalid_ops.rs`.
17+
- The binary and `import_batch` cases in `crates/loro-internal/src/tests/import_atomicity.rs`.
18+
- The `ChangeStore` rollback and KV lookup tests in `src/oplog/change_store.rs`.
19+
- `crates/loro-wasm/tests/movable_list_invalid_ops.test.ts`.
20+
21+
## Rejected with `Err`: ops with no meaning
22+
23+
1. **Positions no real sequence can reach.** `InnerListOp::check_positions`
24+
(`src/container/list/list_op.rs`) runs when binary (`outdated_encode_reordered::decode_op`)
25+
and JSON (`json_schema::decode_op`) ops are decoded.
26+
- It rejects any position of a List/MovableList/Text op at or past
27+
`UNKNOWN_SPAN_LEN - 1` (`src/container/richtext/tracker.rs`). That is the length
28+
of the tracker's placeholder span for unreplayed history.
29+
- Positions past it panicked inside the tracker before `validate_diff` could run.
30+
- This check needs no history, so it covers every import path, including
31+
detached imports.
32+
- **It is a hard document limit, not only an import check.** `decode_op` is also
33+
how a doc parses its own stored change blocks and snapshot blocks
34+
(`block_encode.rs`), so a sequence position ≥ 1,073,741,822 is rejected there
35+
too. That is deliberate:
36+
- Snapshot and update blocks are external input as well.
37+
- A sequence that long (≈1.07e9 items, ≥1 GB of text) cannot be diffed or
38+
imported by any peer anyway: the tracker panics before this check existed.
39+
- Skipping the check for "own" blocks would need a trust flag threaded through
40+
block decoding.
41+
2. **Unknown or out-of-history element.** `OpLog::validate_movable_list_elem_refs_in_import_scope`
42+
(`src/oplog.rs`) checks each `Move`/`Set`. `elem_id` must be an `Insert` op in the
43+
same container that lies in the op's causal history (see the causal check below).
44+
It returns `LoroError::DecodeError`.
45+
- The references are recorded by `OpLog::insert_new_change` into the open
46+
`ImportRollback`, so they cover:
47+
- directly imported changes,
48+
- pending changes the import unlocks,
49+
- every blob of an `import_batch`.
50+
- The validator reads the recorded list, so it never re-reads the imported range
51+
from the change store.
52+
- `OpLog::resolve_movable_list_elem` finds the element with one
53+
`ChangeStore::get_change_by_lamport_lte` lookup. That lookup scans both parsed
54+
and KV-only blocks, so a miss means the lamport is not in the stored history.
55+
- What an element resolves to (insert op, container) does not depend on the op, so
56+
each pass caches it per `elem_id`. Moves/sets keep hitting the same elements.
57+
- The causal check reads the cached start version of the op's DAG node
58+
(`AppDag::ensure_vv_for`); earlier ops of the same node are by the same peer.
59+
`AppDag::get_vv` would clone and insert into a version vector per op.
60+
- Only on a shallow doc does a miss fall back to the shallow-root state
61+
(`ContainerHistoryCache::shallow_root_has_movable_list_elem`): an op after the
62+
root can only see pre-root elements that are still alive at the root.
63+
- Call sites:
64+
- the attached branch of `import_changes_and_apply_delta_to_state_if_needed`
65+
(when `rollback_enabled`);
66+
- its detached branch (it opens its own scope when the preflight asks for one
67+
and no batch scope is open);
68+
- `BatchImportGuard::finish`, which validates the whole batch once before the
69+
closing checkout and rolls the whole batch back on failure
70+
([import-batch-atomicity.md](import-batch-atomicity.md)).
71+
3. **Out-of-bounds `from`/`to` that survive the import.** `MovableListState::validate_diff`
72+
bounds-checks the list delta in op-index space (dead list items count), the same
73+
way `ListState::validate_diff` does.
74+
- This check needs state, so a detached import cannot run it (see the gaps below).
75+
- It checks the composed delta, so an out-of-bounds move that a later op in the
76+
same import cancels (e.g. `move to: 9` followed by `delete pos: 9`) is accepted.
77+
Every path gives the same result for it.
78+
79+
### Why every such import gets a rollback scope
80+
81+
`ImportChangesPreflight` (`OpLog::preflight_import_changes`) and
82+
`PendingChanges::has_state_apply_rollback_ops` set `needs_state_apply_rollback`
83+
for List, MovableList and Tree ops.
84+
- The preflight inspects the ops of **every** new change, including ones whose
85+
deps are not in the DAG yet. Those deps may be earlier changes of the same import,
86+
which then unlock them during the import.
87+
- It used to skip such changes before looking at their ops. That let
88+
`[C1: map-only change, C2: forged op depending on C1]` skip both the rollback
89+
scope and the validation. On that path a cross-container move was accepted
90+
silently and put one element in two lists.
91+
92+
## Applied with CRDT semantics: move/set of a deleted element
93+
94+
A `Move`/`Set` that is causally after the delete of its element cannot come from
95+
the public API. It is still **accepted**, with the same meaning as a *concurrent*
96+
move/set of a deleted element. The move creates a new position, so the element
97+
comes back with its last value. The set changes a value nobody can see. Why:
98+
99+
- It gives a forger nothing new. The forger can declare deps from before the
100+
delete and get the same effect from a legitimate concurrent move.
101+
- Rejecting it consistently is not affordable. Checkout-mode replay (`checkout`,
102+
`import_batch`'s closing reattach, concurrent imports, detached → `attach`) would
103+
have to know whether the element is alive at the op's version. That needs an
104+
index from list items to the deletes that cover them. If only the linear path
105+
rejected it, `import` and `import_batch` of the same bytes would disagree.
106+
107+
The one path that could not apply it is the forward fast path
108+
(`DiffMode::Linear`/`ImportGreaterUpdates`). There the movable-list diff only
109+
carries the fields the op touched, and `apply_diff_and_convert` fills in the rest
110+
from the element in `DocState`. Detection and fallback:
111+
- `MovableListState::references_absent_elem` / `DocState::needs_checkout_diff`
112+
detect a delta that moves/sets an element missing from the state.
113+
- `recalc_in_checkout_mode_if_needed` (`src/loro.rs`) recomputes that import's
114+
diff with `DiffCalculator::new(true)` (Persist, so always Checkout mode). This
115+
matches a full replay.
116+
- Honest imports never trigger it.
117+
- `validate_diff` still returns `Err` for such a delta as a backstop.
118+
119+
## Change-store pitfalls this work exposed
120+
121+
- **`decode_block_range` read a version varint that blocks do not have.**
122+
`encode_block` writes a postcard `EncodedBlock` with no version prefix, so every
123+
field was read one position off.
124+
- History: `3d2d9d9c` (2024-09, "refactor: optimize block encoder") removed the
125+
`version` field from the block, its encoder and the full decoder, but not from
126+
`decode_block_range`.
127+
- Every `loro-crdt@1.0.0*` release contains that commit, so no 1.x build ever wrote
128+
version-prefixed blocks and the fix cannot make stored data unreadable.
129+
130+
Consequences:
131+
- Blocks that do not start at counter 0 were skipped.
132+
- Block `0@P` got its lamport length as its lamport start.
133+
- Lamport lookups on KV-only blocks were wrong:
134+
`get_change_with_lamport_lte` / JS `getChangeAtLamport` after a snapshot load
135+
(this was also broken on main), and the element validator above.
136+
- Existing tests missed it because they parsed every block first.
137+
- **Rollback must not leave an old block in front of the next insert.**
138+
`insert_change_inner` merges a change into the cached block right before it.
139+
- Failure sequence: an import creates a peer's newest block without loading the
140+
older ones; a read during the scope (such as the validator's lamport lookup)
141+
caches an older KV block; rollback removes the newest block. The next insert of
142+
the same change then panicked with "counter should be continuous".
143+
- `ChangeStore::rollback_import` therefore evicts the flushed blocks of every peer
144+
the rollback touched. They reload from KV on demand.
145+
- **Cheap rollback records.** `ChangeStoreRollback` keeps a `BlockShape` for each
146+
unflushed pre-scope block an import appended to: change count, the last change's
147+
op count and its last op. Rollback truncates back to it.
148+
- Imports only append (push changes, or push ops that may merge into the last op),
149+
so the shape is enough.
150+
- Flushed blocks need no record: their KV copy is the pre-scope version.
151+
- The earlier record was an `Arc` of the whole block, which made the next append
152+
copy the block's changes on every import under a scope. That made small
153+
movable-list imports about 40% slower, and List/Tree imports on main already
154+
paid it (they are about 45% faster now).
155+
156+
## Known gaps (not fixed here)
157+
158+
- A `Move` whose `from` does not point at its element is accepted. The tracker
159+
removes whatever list item sits at `from`, so another element can disappear.
160+
The result is the same on every path, but it does not match any honest op.
161+
- An explicitly detached doc that imports an op which only state validation
162+
rejects (an out-of-bounds list insert, or a movable-list move out of bounds)
163+
panics on `attach()`/`checkout_to_latest`. Those return `()` and `expect` the
164+
checkout. This affects every container type and predates this change.
165+
- The oplog inside a `FastSnapshot` is not validated op by op, because that would
166+
decode every block. A forged snapshot can still reach the diff calculator's
167+
unwraps on a later checkout.
168+
- A forged change parked as pending fails the import that later unlocks it, so
169+
that import is rolled back, the same as for list bounds errors.

‎crates/loro-internal/AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ over graceful degradation.
4444
mergeable container regression tests.
4545
- `src/tests/import_atomicity.rs`: import rollback and malformed-input
4646
regressions.
47+
- Movable-list `Move`/`Set` element validation on import, plus change-store
48+
rollback records and KV block-range decoding:
49+
[../../context/movable-list-op-validation.md](../../context/movable-list-op-validation.md).
4750
- `import_batch` force-detach, its batch-wide rollback scope, and the
4851
never-exit-detached invariant:
4952
[../../context/import-batch-atomicity.md](../../context/import-batch-atomicity.md).

‎crates/loro-internal/src/container/list/list_op.rs‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ use std::ops::Range;
22

33
use append_only_bytes::BytesSlice;
44
use enum_as_inner::EnumAsInner;
5-
use loro_common::{ContainerType, HasId, HasIdSpan, IdLp, LoroValue, ID};
5+
use loro_common::{ContainerType, HasId, HasIdSpan, IdLp, LoroError, LoroResult, LoroValue, ID};
66
use rle::{HasLength, Mergable, Sliceable};
77
use serde::{Deserialize, Serialize};
88

99
use crate::{
1010
container::richtext::richtext_state::unicode_to_utf8_index,
11+
container::richtext::tracker::UNKNOWN_SPAN_LEN,
1112
container::richtext::TextStyleInfoFlag,
1213
op::{ListSlice, SliceRange},
1314
InternalString,
@@ -88,6 +89,37 @@ impl ListOp<'_> {
8889
}
8990

9091
impl InnerListOp {
92+
/// Reject positions that no real sequence can reach.
93+
///
94+
/// Diff calculation replays ops on a tracker whose unreplayed history is one
95+
/// placeholder span of [`UNKNOWN_SPAN_LEN`] items, and positions past it panic
96+
/// inside the tracker. Positions are op-index based (deleted items included), so
97+
/// a valid document stays many orders of magnitude below this. Smaller
98+
/// out-of-bounds positions are rejected later by state validation.
99+
pub(crate) fn check_positions(&self) -> LoroResult<()> {
100+
const MAX_POS: i64 = UNKNOWN_SPAN_LEN as i64 - 1;
101+
let in_range = |pos: i64| (0..=MAX_POS).contains(&pos);
102+
let ok = match self {
103+
InnerListOp::Insert { pos, .. } => in_range(*pos as i64),
104+
InnerListOp::InsertText { pos, .. } => in_range(*pos as i64),
105+
InnerListOp::Delete(span) => {
106+
in_range(span.start() as i64) && in_range(span.last() as i64)
107+
}
108+
InnerListOp::Move { from, to, .. } => in_range(*from as i64) && in_range(*to as i64),
109+
InnerListOp::StyleStart { start, end, .. } => {
110+
in_range(*start as i64) && in_range(*end as i64)
111+
}
112+
InnerListOp::Set { .. } | InnerListOp::StyleEnd => true,
113+
};
114+
if ok {
115+
Ok(())
116+
} else {
117+
Err(LoroError::DecodeError(
118+
format!("Sequence op position is out of range: {self:?}").into_boxed_str(),
119+
))
120+
}
121+
}
122+
91123
pub fn new_del(id: ID, pos: usize, len: isize) -> Self {
92124
assert!(len != 0);
93125
Self::Delete(DeleteSpanWithId {

‎crates/loro-internal/src/container/richtext.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub(crate) mod str_slice;
1717
mod style_range_map;
1818
#[cfg(all(test, miri))]
1919
mod tinyvec;
20-
mod tracker;
20+
pub(crate) mod tracker;
2121

2222
use crate::{change::Lamport, delta::StyleMeta, utils::string_slice::StringSlice, InternalString};
2323
use fugue_span::*;

‎crates/loro-internal/src/container/richtext/tracker.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ use super::{
1919

2020
mod crdt_rope;
2121
mod id_to_cursor;
22+
23+
/// Length of the placeholder span that stands for history the tracker has not
24+
/// replayed (`Tracker::new_with_unknown`). Op positions must stay below it; see
25+
/// `InnerListOp::check_positions`.
26+
pub(crate) const UNKNOWN_SPAN_LEN: u32 = u32::MAX / 4;
27+
2228
pub(crate) use crdt_rope::CrdtRopeDelta;
2329

2430
#[derive(Debug)]
@@ -46,7 +52,7 @@ impl Tracker {
4652
};
4753

4854
let result = this.rope.tree.push(FugueSpan {
49-
content: RichtextChunk::new_unknown(u32::MAX / 4),
55+
content: RichtextChunk::new_unknown(UNKNOWN_SPAN_LEN),
5056
id: IdFull::new(UNKNOWN_PEER_ID, 0, 0),
5157
real_id: None,
5258
status: Status::default(),
@@ -56,7 +62,7 @@ impl Tracker {
5662
});
5763
this.id_to_cursor.insert_without_split(
5864
ID::new(UNKNOWN_PEER_ID, 0),
59-
id_to_cursor::Cursor::new_insert(result.leaf, u32::MAX as usize / 4),
65+
id_to_cursor::Cursor::new_insert(result.leaf, UNKNOWN_SPAN_LEN as usize),
6066
);
6167
this
6268
}

‎crates/loro-internal/src/encoding/json_schema.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,9 @@ fn decode_op(op: json::JsonOp, arena: &SharedArena, peers: &Option<Vec<PeerID>>)
10491049
}
10501050
} // Note: The Future Type need try to parse Op from the unknown content
10511051
};
1052+
if let InnerContent::List(list_op) = &content {
1053+
list_op.check_positions()?;
1054+
}
10521055
Ok(Op {
10531056
counter,
10541057
container: idx,

‎crates/loro-internal/src/encoding/outdated_encode_reordered.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,10 @@ pub(crate) fn decode_op(
472472
}),
473473
};
474474

475+
if let crate::op::InnerContent::List(list_op) = &content {
476+
list_op.check_positions()?;
477+
}
478+
475479
Ok(content)
476480
}
477481

0 commit comments

Comments
 (0)