Skip to content

Commit d32efe0

Browse files
committed
Merge branch 'feat-rust'
2 parents 310b787 + f269fbf commit d32efe0

File tree

11 files changed

+183
-11
lines changed

11 files changed

+183
-11
lines changed

rust/loro-protocol/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
edition = "2021"
55
description = "Rust implementation of the Loro Syncing Protocol encoder/decoder"
66
license = "MIT"
7-
repository = "https://example.com/"
7+
repository = "https://github.com/loro-dev/protocol"
88
readme = "README.md"
99

1010
[lib]

rust/loro-protocol/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Rust implementation of the Loro syncing protocol encoder/decoder. Mirrors the Ty
66
- Encode/decode Join, DocUpdate, FragmentHeader/Fragment, UpdateError, and Leave messages
77
- 256 KiB message size guard to match the wire spec
88
- Bytes utilities (`BytesWriter`, `BytesReader`) for varint/varbytes/varstring
9+
- `%ELO` container parsing; Rust-side encryption helpers are WIP and may evolve
910

1011
## Usage
1112

rust/loro-protocol/src/elo.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
//! for container decode and plaintext header parsing. This module is intentionally
44
//! crypto-free; consumers can use the parsed `aad` (exact header bytes) and `iv`
55
//! with their own AES-GCM bindings if desired.
6+
//! NOTE: `%ELO` support on the Rust side is work-in-progress; only the container
7+
//! and header parsing surface is considered stable today.
68
79
use crate::bytes::BytesReader;
810

@@ -144,4 +146,3 @@ fn compare_bytes(a: &[u8], b: &[u8]) -> i32 {
144146
}
145147
(a.len() as i32) - (b.len() as i32)
146148
}
147-

rust/loro-protocol/src/encoding.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,30 @@ mod tests {
262262
_ => panic!("wrong decoded type"),
263263
}
264264
}
265+
266+
#[test]
267+
fn encode_rejects_room_id_over_limit() {
268+
let long_room = "x".repeat(129);
269+
let msg = ProtocolMessage::JoinRequest {
270+
crdt: CrdtType::Loro,
271+
room_id: long_room,
272+
auth: vec![],
273+
version: vec![],
274+
};
275+
let err = encode(&msg).unwrap_err();
276+
assert!(err.contains("Room ID too long"));
277+
}
278+
279+
#[test]
280+
fn encode_rejects_payload_over_max_size() {
281+
// Build an intentionally oversized payload (header + one giant update)
282+
let big_update = vec![0u8; MAX_MESSAGE_SIZE + 1024];
283+
let msg = ProtocolMessage::DocUpdate {
284+
crdt: CrdtType::Loro,
285+
room_id: "room-oversized".into(),
286+
updates: vec![big_update],
287+
};
288+
let err = encode(&msg).unwrap_err();
289+
assert!(err.contains("exceeds maximum"));
290+
}
265291
}

rust/loro-protocol/tests/elo_normative_vector.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ fn normative_vector_deltaspan_header_and_ct_align_with_spec() {
2929
let key_id = "k1";
3030
let iv = hex_to_bytes("0x86bcad09d5e7e3d70503a57e");
3131
assert_eq!(iv.len(), 12);
32-
let plaintext = hex_to_bytes("0x01026869"); // varUint 1, varBytes("hi")
32+
let _plaintext = hex_to_bytes("0x01026869"); // varUint 1, varBytes("hi")
3333

3434
// Encode header exactly as spec (this becomes AAD)
3535
let mut w = BytesWriter::new();
@@ -73,4 +73,3 @@ fn normative_vector_deltaspan_header_and_ct_align_with_spec() {
7373
// Also verify magic mapping exists (parity with TS)
7474
assert_eq!(CrdtType::Elo.magic_bytes(), *b"%ELO");
7575
}
76-

rust/loro-websocket-client/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ version = "0.1.0"
44
edition = "2021"
55
description = "Async WebSocket client for the Loro protocol"
66
license = "MIT"
7+
repository = "https://github.com/loro-dev/protocol"
8+
readme = "README.md"
79

810
[lib]
911
name = "loro_websocket_client"
1012
path = "src/lib.rs"
1113

1214
[dependencies]
13-
loro-protocol = { path = "../loro-protocol" }
15+
loro-protocol = { version = "0.1.0", path = "../loro-protocol" }
1416
tokio = { version = "1", features = ["rt", "macros", "net", "time", "sync"] }
1517
tokio-tungstenite = "0.27"
1618
futures-util = { version = "0.3", default-features = false, features = ["sink"] }

rust/loro-websocket-client/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Async WebSocket client for the Loro protocol. Exposes:
44
- Low-level `Client` to send/receive raw `loro_protocol::ProtocolMessage`.
55
- High-level `LoroWebsocketClient` that joins rooms and mirrors updates into a `loro::LoroDoc`, matching the TypeScript client behavior.
66

7+
> %ELO support is WIP: the Rust adaptor currently ships snapshot-only packaging for encrypted docs and APIs may change.
8+
79
## Quick start
810

911
```rust
@@ -24,7 +26,7 @@ let _room = client.join_loro("room1", doc.clone()).await?;
2426
## Features
2527
- Handles protocol keepalive (`"ping"/"pong"`) and filters control frames.
2628
- Automatic fragmentation/reassembly thresholds aligned with the server.
27-
- %ELO adaptor helpers to encrypt/decrypt containers alongside Loro.
29+
- %ELO adaptor helpers to encrypt/decrypt containers alongside Loro (experimental snapshot-only flow).
2830

2931
## Tests
3032

rust/loro-websocket-client/examples/elo_index_client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2727
"sender" => {
2828
// Prepare desired state before join so adaptor sends it as initial snapshot
2929
{
30-
let mut d = doc.lock().await;
30+
let d = doc.lock().await;
3131
d.get_text("t").insert(0, "hi").unwrap();
3232
d.commit();
3333
}

rust/loro-websocket-client/src/lib.rs

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,146 @@ impl Client {
193193
#[cfg(test)]
194194
mod tests {
195195
use super::*;
196+
196197
#[test]
197198
fn client_error_display() {
198199
let e = ClientError::Protocol("bad".into());
199200
assert!(format!("{}", e).contains("protocol error"));
200201
}
202+
203+
#[derive(Default)]
204+
struct RecordingAdaptor {
205+
updates: Arc<Mutex<Vec<Vec<u8>>>>,
206+
}
207+
208+
#[async_trait::async_trait]
209+
impl CrdtDocAdaptor for RecordingAdaptor {
210+
fn crdt_type(&self) -> CrdtType {
211+
CrdtType::Loro
212+
}
213+
214+
async fn version(&self) -> Vec<u8> {
215+
Vec::new()
216+
}
217+
218+
async fn set_ctx(&mut self, _ctx: CrdtAdaptorContext) {}
219+
220+
async fn handle_join_ok(
221+
&mut self,
222+
_permission: protocol::Permission,
223+
_version: Vec<u8>,
224+
) {
225+
}
226+
227+
async fn apply_update(&mut self, updates: Vec<Vec<u8>>) {
228+
self.updates.lock().await.extend(updates);
229+
}
230+
}
231+
232+
#[tokio::test(flavor = "current_thread")]
233+
async fn fragment_reassembly_delivers_updates_in_order() {
234+
let (tx, _rx) = mpsc::unbounded_channel::<Message>();
235+
let rooms = Arc::new(Mutex::new(HashMap::new()));
236+
let pending = Arc::new(Mutex::new(HashMap::new()));
237+
let adaptors = Arc::new(Mutex::new(HashMap::new()));
238+
let pre_join_buf = Arc::new(Mutex::new(HashMap::new()));
239+
let frag_batches = Arc::new(Mutex::new(HashMap::new()));
240+
let config = Arc::new(ClientConfig::default());
241+
242+
let worker = ConnectionWorker::new(
243+
tx,
244+
rooms,
245+
pending,
246+
adaptors.clone(),
247+
pre_join_buf,
248+
frag_batches,
249+
config,
250+
);
251+
252+
let room_id = "room-frag".to_string();
253+
let key = RoomKey {
254+
crdt: CrdtType::Loro,
255+
room: room_id.clone(),
256+
};
257+
let collected = Arc::new(Mutex::new(Vec::<Vec<u8>>::new()));
258+
adaptors.lock().await.insert(
259+
key.clone(),
260+
Box::new(RecordingAdaptor {
261+
updates: collected.clone(),
262+
}),
263+
);
264+
265+
let batch_id = protocol::BatchId([1, 2, 3, 4, 5, 6, 7, 8]);
266+
worker
267+
.handle_message(ProtocolMessage::DocUpdateFragmentHeader {
268+
crdt: CrdtType::Loro,
269+
room_id: room_id.clone(),
270+
batch_id,
271+
fragment_count: 2,
272+
total_size_bytes: 10,
273+
})
274+
.await;
275+
// Send fragments out of order to ensure slot ordering is respected
276+
worker
277+
.handle_message(ProtocolMessage::DocUpdateFragment {
278+
crdt: CrdtType::Loro,
279+
room_id: room_id.clone(),
280+
batch_id,
281+
index: 1,
282+
fragment: b"world".to_vec(),
283+
})
284+
.await;
285+
worker
286+
.handle_message(ProtocolMessage::DocUpdateFragment {
287+
crdt: CrdtType::Loro,
288+
room_id,
289+
batch_id,
290+
index: 0,
291+
fragment: b"hello".to_vec(),
292+
})
293+
.await;
294+
295+
let updates = collected.lock().await;
296+
assert_eq!(updates.as_slice(), &[b"helloworld".to_vec()]);
297+
}
298+
299+
#[tokio::test(flavor = "current_thread")]
300+
async fn elo_snapshot_container_roundtrips_plaintext() {
301+
let doc = Arc::new(Mutex::new(LoroDoc::new()));
302+
let key = [7u8; 32];
303+
let adaptor = EloDocAdaptor::new(doc, "kid", key)
304+
.with_iv_factory(Arc::new(|| [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]));
305+
let plaintext = b"hello-elo".to_vec();
306+
307+
let container = adaptor.encode_elo_snapshot_container(&plaintext);
308+
let records =
309+
protocol::elo::decode_elo_container(&container).expect("container should decode");
310+
assert_eq!(records.len(), 1);
311+
let parsed =
312+
protocol::elo::parse_elo_record_header(records[0]).expect("header should parse");
313+
match parsed.header {
314+
protocol::elo::EloHeader::Snapshot(hdr) => {
315+
assert_eq!(hdr.key_id, "kid");
316+
assert_eq!(hdr.iv, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
317+
let cipher = aes_gcm::Aes256Gcm::new_from_slice(&key).unwrap();
318+
let decrypted = cipher
319+
.decrypt(
320+
aes_gcm::Nonce::from_slice(&hdr.iv),
321+
aes_gcm::aead::Payload {
322+
msg: parsed.ct,
323+
aad: parsed.aad,
324+
},
325+
)
326+
.unwrap();
327+
assert_eq!(decrypted, plaintext);
328+
}
329+
_ => panic!("expected snapshot header"),
330+
}
331+
assert!(matches!(
332+
parsed.kind,
333+
protocol::elo::EloRecordKind::Snapshot
334+
));
335+
}
201336
}
202337

203338
#[derive(Clone)]
@@ -1017,6 +1152,8 @@ impl Drop for LoroDocAdaptor {
10171152
}
10181153

10191154
// --- EloDocAdaptor: E2EE Loro (minimal snapshot-only packaging) ---
1155+
/// Experimental %ELO adaptor. Snapshot-only packaging is implemented today;
1156+
/// delta packaging and API stability are WIP and may change.
10201157
pub struct EloDocAdaptor {
10211158
doc: Arc<Mutex<LoroDoc>>,
10221159
ctx: Option<CrdtAdaptorContext>,
@@ -1146,7 +1283,7 @@ impl CrdtDocAdaptor for EloDocAdaptor {
11461283

11471284
async fn handle_join_ok(&mut self, _permission: protocol::Permission, _version: Vec<u8>) {
11481285
// On join, send a full encrypted snapshot to establish baseline.
1149-
// TODO: REVIEW [elo-packaging]
1286+
// WIP: %ELO snapshot-only packaging; TODO: REVIEW [elo-packaging]
11501287
// This minimal implementation uses snapshot-only packaging and empty VV.
11511288
// It is correct but not optimal; consider delta packaging in a follow-up.
11521289
if let Ok(snap) = self.doc.lock().await.export(loro::ExportMode::Snapshot) {

rust/loro-websocket-server/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,23 @@ version = "0.1.0"
44
edition = "2021"
55
description = "Simple async WebSocket server skeleton for the Loro protocol"
66
license = "MIT"
7+
repository = "https://github.com/loro-dev/protocol"
8+
readme = "README.md"
79

810
[lib]
911
name = "loro_websocket_server"
1012
path = "src/lib.rs"
1113

1214
[dependencies]
13-
loro-protocol = { path = "../loro-protocol" }
15+
loro-protocol = { version = "0.1.0", path = "../loro-protocol" }
1416
tokio = { version = "1", features = ["rt", "macros", "net", "sync", "time"] }
1517
tokio-tungstenite = "0.27"
1618
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
1719
loro = "1"
1820
tracing = "0.1"
1921

2022
[dev-dependencies]
21-
loro-websocket-client = { path = "../loro-websocket-client" }
23+
loro-websocket-client = { version = "0.1.0", path = "../loro-websocket-client" }
2224
tokio = { version = "1", features = ["rt", "macros", "net", "sync", "time", "process"] }
2325
rusqlite = { version = "0.31", features = ["bundled"] }
2426
clap = { version = "4", features = ["derive"] }

0 commit comments

Comments
 (0)