mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 04:16:46 -06:00
figcraft-server hosts many files over the same hub the app uses for sessions, persists each as a checksummed snapshot plus an append-only log (synced before replies, compacted, damaged files skipped and reported) so restarts lose nothing confirmed, and gives per-file share links with edit or view access (hashed 128-bit tokens, revocable). The app can publish a file to a server and join server-hosted files; view-only tabs refuse edits. Protocol version 2.
199 lines
9.2 KiB
Rust
199 lines
9.2 KiB
Rust
//! Message encodings: round trips, hostile input, and the sizes of typical messages.
|
|
|
|
use figcraft_doc::{Document, NodeId, NodeKind};
|
|
|
|
use crate::change::{Change, Json, Prop};
|
|
use crate::message::{
|
|
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, Hello, LinkInfo, Manage, NewLink, PROTOCOL, Peer, Presence,
|
|
Processed, Resume, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
|
};
|
|
use crate::sim::Rng;
|
|
use crate::{Error, Replica, diff};
|
|
|
|
/// A typical edit: one layer dragged (its transform).
|
|
fn moved_layer() -> ClientMsg {
|
|
let mut doc = Document::new("f");
|
|
let page = doc.first_page().unwrap();
|
|
doc.set_client_id(3);
|
|
let r = doc.create(page, NodeKind::Rectangle, "Rectangle 1", 120.0, 80.0, 100.0, 60.0).unwrap();
|
|
let a = Replica::new(doc);
|
|
let mut b = a.document().clone();
|
|
b.get_mut(r).unwrap().transform = figcraft_geom::Affine::translate((164.5, 92.25));
|
|
ClientMsg::Changes { base_seq: 1234, batch_id: 56, changes: diff(&a, &b).unwrap().changes }
|
|
}
|
|
|
|
fn cursor() -> ClientMsg {
|
|
ClientMsg::Presence(Presence { cursor: Some((NodeId(1), 512.5, 384.25)), ..Default::default() })
|
|
}
|
|
|
|
fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|
let peer = Peer { client_id: 4, name: "Ada".into(), color: "#FF8800".into() };
|
|
let set = Change::Set { id: NodeId((4 << 32) | 9), prop: Prop::Name, value: Json::of("Café ☕").unwrap() };
|
|
let client = vec![
|
|
ClientMsg::Hello(Hello {
|
|
protocol: PROTOCOL,
|
|
file: "f".into(),
|
|
token: "t".into(),
|
|
name: "Ada".into(),
|
|
color: "#FF8800".into(),
|
|
client_version: "0.2.0".into(),
|
|
resume: Some(Resume { session: 77, client_id: 4, seq: 10 }),
|
|
}),
|
|
moved_layer(),
|
|
cursor(),
|
|
ClientMsg::Presence(Presence {
|
|
cursor: None,
|
|
selection: Some(vec![NodeId(2), NodeId(3)]),
|
|
viewport: Some((NodeId(1), 0.0, 0.0, 1200.0, 800.0)),
|
|
editing_text: Some(Box::new(EditingText { node: NodeId(7), caret: 3, selection: Some((1, 3)) })),
|
|
}),
|
|
ClientMsg::Ack { seq: 9 },
|
|
ClientMsg::Ping,
|
|
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: "k".into(), client_version: "0.3.1".into() }),
|
|
ClientMsg::Admin(Admin::Publish { name: "Café".into(), snapshot: Replica::new(Document::new("Up")).snapshot().unwrap() }),
|
|
ClientMsg::Admin(Admin::Files),
|
|
ClientMsg::Admin(Admin::Links { file: "f".into() }),
|
|
ClientMsg::Admin(Admin::CreateLink { file: "f".into(), access: Access::View, label: "Review".into() }),
|
|
ClientMsg::Admin(Admin::Revoke { file: "f".into(), link: "l".into() }),
|
|
ClientMsg::Admin(Admin::Remove { file: "f".into() }),
|
|
];
|
|
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
|
let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 };
|
|
let link = LinkInfo { id: "l".into(), file: "f".into(), access: Access::Edit, label: String::new(), created: 6 };
|
|
let new_link = NewLink { link: link.clone(), token: "t".into() };
|
|
let server = vec![
|
|
ServerMsg::Welcome {
|
|
session: 77,
|
|
client_id: 4,
|
|
seq: 10,
|
|
catch_up: CatchUp::Snapshot(snapshot),
|
|
processed: Processed::default(),
|
|
peers: vec![peer.clone()],
|
|
access: Access::Edit,
|
|
},
|
|
ServerMsg::Welcome {
|
|
session: 77,
|
|
client_id: 4,
|
|
seq: 11,
|
|
catch_up: CatchUp::Delta(vec![Accepted { seq: 11, author: 5, batch_id: 1, changes: vec![set.clone()] }]),
|
|
processed: Processed { last_batch: 7, refused: vec![3, 6] },
|
|
peers: vec![],
|
|
access: Access::View,
|
|
},
|
|
ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }),
|
|
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
|
|
ServerMsg::Presence { client_id: 4, presence: Presence::default() },
|
|
ServerMsg::PeerJoined(peer),
|
|
ServerMsg::PeerLeft { client_id: 4 },
|
|
ServerMsg::Refused { reason: "full".into() },
|
|
ServerMsg::Pong,
|
|
ServerMsg::Admin(Box::new(AdminReply::Ready)),
|
|
ServerMsg::Admin(Box::new(AdminReply::Published { file: file.clone(), edit: new_link.clone(), view: new_link.clone() })),
|
|
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file]))),
|
|
ServerMsg::Admin(Box::new(AdminReply::Links(vec![link]))),
|
|
ServerMsg::Admin(Box::new(AdminReply::Link(new_link))),
|
|
ServerMsg::Admin(Box::new(AdminReply::Done)),
|
|
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
|
|
];
|
|
(client, server)
|
|
}
|
|
|
|
#[test]
|
|
fn every_message_round_trips_in_both_forms() {
|
|
let (client, server) = samples();
|
|
for m in &client {
|
|
assert_eq!(&decode::<ClientMsg>(&encode(m).unwrap(), usize::MAX).unwrap(), m);
|
|
assert_eq!(&decode_json::<ClientMsg>(&encode_json(m).unwrap(), usize::MAX).unwrap(), m);
|
|
}
|
|
for m in &server {
|
|
assert_eq!(&decode::<ServerMsg>(&encode(m).unwrap(), usize::MAX).unwrap(), m);
|
|
assert_eq!(&decode_json::<ServerMsg>(&encode_json(m).unwrap(), usize::MAX).unwrap(), m);
|
|
}
|
|
// The JSON form is readable: the type, then its fields; values as JSON.
|
|
let json = encode_json(&moved_layer()).unwrap();
|
|
assert!(json.starts_with(r#"{"changes":{"base_seq":1234,"batch_id":56,"changes":[{"set":"#), "{json}");
|
|
assert!(json.contains(r#""prop":"transform","value":[1.0,0.0,0.0,1.0,164.5,92.25]"#), "{json}");
|
|
// The binary form starts with the wire version and carries values as JSON text.
|
|
let bin = encode(&moved_layer()).unwrap();
|
|
assert_eq!(bin[0], WIRE_VERSION);
|
|
assert!(bin.windows(5).any(|w| w == b"164.5"));
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_bytes_are_refused_without_panicking() {
|
|
assert!(matches!(decode::<ClientMsg>(&[], 100), Err(Error::Decode(_))));
|
|
assert!(matches!(decode::<ClientMsg>(&[WIRE_VERSION + 1, 4], 100), Err(Error::Decode(_))));
|
|
let ping = encode(&ClientMsg::Ping).unwrap();
|
|
let mut trailing = ping.clone();
|
|
trailing.push(0);
|
|
assert!(decode::<ClientMsg>(&trailing, 100).is_err(), "trailing bytes");
|
|
assert!(decode::<ClientMsg>(&ping, 1).is_err(), "over the size limit");
|
|
assert!(decode_json::<ClientMsg>("{\"ping\":null}", 3).is_err(), "over the size limit");
|
|
// A huge length prefix doesn't allocate it.
|
|
let mut huge = vec![WIRE_VERSION, 1];
|
|
huge.extend([0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
|
assert!(decode::<ClientMsg>(&huge, 1 << 20).is_err());
|
|
// Deeply nested JSON inside a value is refused by the JSON parser's depth limit.
|
|
let deep = format!("{}{}", "[".repeat(100_000), "]".repeat(100_000));
|
|
let evil = format!(r#"{{"changes":{{"base_seq":0,"batch_id":0,"changes":[{{"set":{{"id":1,"prop":"name","value":{deep}}}}}]}}}}"#);
|
|
assert!(decode_json::<ClientMsg>(&evil, usize::MAX).is_err());
|
|
// The same value in a binary message (JSON text inside).
|
|
let mut bin = encode(&ClientMsg::Changes {
|
|
base_seq: 0,
|
|
batch_id: 0,
|
|
changes: vec![Change::Set { id: NodeId(1), prop: Prop::Name, value: Json::of("x").unwrap() }],
|
|
})
|
|
.unwrap();
|
|
let at = bin.windows(3).position(|w| w == b"\"x\"").unwrap();
|
|
bin.truncate(at - 1);
|
|
let mut len = deep.len();
|
|
while len >= 0x80 {
|
|
bin.push((len as u8 & 0x7F) | 0x80);
|
|
len >>= 7;
|
|
}
|
|
bin.push(len as u8);
|
|
bin.extend(deep.as_bytes());
|
|
assert!(matches!(decode::<ClientMsg>(&bin, usize::MAX), Err(Error::Decode(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn fuzzed_messages_never_panic() {
|
|
let (client, server) = samples();
|
|
let valid: Vec<Vec<u8>> = client.iter().map(|m| encode(m).unwrap()).chain(server.iter().map(|m| encode(m).unwrap())).collect();
|
|
let mut rng = Rng::new(42);
|
|
for round in 0..20_000 {
|
|
let mut bytes =
|
|
if round % 4 == 0 { (0..rng.below(64)).map(|_| rng.next_u64() as u8).collect() } else { rng.pick(&valid).cloned().unwrap_or_default() };
|
|
// Flip, insert or cut a few bytes.
|
|
for _ in 0..1 + rng.below(4) {
|
|
if bytes.is_empty() {
|
|
break;
|
|
}
|
|
let i = rng.below(bytes.len());
|
|
match rng.below(3) {
|
|
0 => bytes[i] = rng.next_u64() as u8,
|
|
1 => bytes.insert(i, rng.next_u64() as u8),
|
|
_ => bytes.truncate(i.max(1)),
|
|
}
|
|
}
|
|
let _ = decode::<ClientMsg>(&bytes, 1 << 20);
|
|
let _ = decode::<ServerMsg>(&bytes, 1 << 20);
|
|
if let Ok(text) = std::str::from_utf8(&bytes) {
|
|
let _ = decode_json::<ClientMsg>(text, 1 << 20);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn message_sizes() {
|
|
// Recorded in docs/collab-protocol.md (Encoding). Binary stays well under JSON.
|
|
let edit = moved_layer();
|
|
let (eb, ej) = (encode(&edit).unwrap().len(), encode_json(&edit).unwrap().len());
|
|
let cur = cursor();
|
|
let (cb, cj) = (encode(&cur).unwrap().len(), encode_json(&cur).unwrap().len());
|
|
println!("typical edit (move a layer): binary {eb} B, JSON {ej} B; cursor update: binary {cb} B, JSON {cj} B");
|
|
assert!(eb < ej && cb < cj);
|
|
assert!(cb <= 32, "a cursor update is tiny: {cb}");
|
|
assert!(eb <= 96, "a move is small: {eb}");
|
|
}
|