Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
Sync and the server
- crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates
- apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids
- server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger)
- engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands

Editing together
- crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence)
- ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows

App shell
- vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...)
- compact (phone) layout with a bottom tab bar

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:46:24 -03:00

413 lines
18 KiB
Rust

//! The `server.*` and `share.*` commands against a stub server: the requests
//! they make, the answers they give and the inputs they refuse.
//!
//! The stub is a plain TCP listener that answers canned JSON and remembers
//! every request line, so the test can assert on the path, the method, the
//! bearer header and the body the server would receive.
mod common;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use common::temp_vault;
use notescraft_engine::{Error, Registry, ServerClient, Session, adversarial_params, bind_folder, register_core};
use serde_json::{Value, json};
/// The token of the bound vault; it must never reach an answer or an error.
const TOKEN: &str = "secret-token";
/// One request the stub received.
#[derive(Debug, Clone)]
struct Request {
/// The HTTP method.
method: String,
/// The path of the request line.
path: String,
/// The `Authorization` header, when there was one.
auth: Option<String>,
/// The body of the request.
body: String,
}
/// A canned answer of the stub.
struct Answer {
/// The status of the answer.
status: u16,
/// The body of the answer.
body: String,
}
/// A stub server: it answers canned JSON and remembers what it was asked.
struct Stub {
/// The base URL of the server.
base: String,
/// Every request the stub received.
seen: Arc<Mutex<Vec<Request>>>,
/// The canned answers, by method and path.
routes: Arc<Mutex<Vec<(String, String, Answer)>>>,
/// Whether the listener thread should stop.
stop: Arc<AtomicBool>,
}
impl Stub {
/// Starts a stub on an ephemeral port.
fn spawn() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
listener.set_nonblocking(true).expect("nonblocking");
let port = listener.local_addr().expect("addr").port();
let seen: Arc<Mutex<Vec<Request>>> = Arc::new(Mutex::new(Vec::new()));
let routes: Arc<Mutex<Vec<(String, String, Answer)>>> = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
let thread_seen = Arc::clone(&seen);
let thread_routes = Arc::clone(&routes);
let thread_stop = Arc::clone(&stop);
std::thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
// An accepted socket inherits the listener's non-blocking mode on some platforms:
// a read that beats the request bytes would then end the connection.
let _ = stream.set_nonblocking(false);
// One thread per connection: the connection stays open, so the
// client may reuse it and no request is ever lost in a close.
let Ok(mut writer) = stream.try_clone() else { continue };
let mut reader = BufReader::new(stream);
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(30)));
let seen = Arc::clone(&thread_seen);
let routes = Arc::clone(&thread_routes);
std::thread::spawn(move || {
while let Some(request) = read_request(&mut reader) {
let answer = {
let routes = routes.lock().expect("routes");
routes
.iter()
.find(|(method, path, _)| *method == request.method && *path == request.path)
.map(|(_, _, answer)| Answer { status: answer.status, body: answer.body.clone() })
};
seen.lock().expect("seen").push(request);
let answer = answer.unwrap_or(Answer { status: 404, body: "{\"error\":\"no such route\"}".to_owned() });
write_answer(&mut writer, &answer);
}
});
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
});
Self { base: format!("http://127.0.0.1:{port}"), seen, routes, stop }
}
/// Adds a canned answer.
fn route(&self, method: &str, path: &str, status: u16, body: &str) {
self.routes.lock().expect("routes").push((method.to_owned(), path.to_owned(), Answer { status, body: body.to_owned() }));
}
/// Every request the stub received.
fn requests(&self) -> Vec<Request> {
self.seen.lock().expect("seen").clone()
}
}
impl Drop for Stub {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// Reads one request of a connection.
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Request> {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let mut parts = line.split_whitespace();
let method = parts.next()?.to_owned();
let path = parts.next()?.to_owned();
let mut auth = None;
let mut length = 0_usize;
loop {
let mut header = String::new();
if reader.read_line(&mut header).ok()? == 0 {
break;
}
let trimmed = header.trim_end();
if trimmed.is_empty() {
break;
}
if let Some((name, value)) = trimmed.split_once(':') {
let name = name.trim().to_ascii_lowercase();
let value = value.trim().to_owned();
if name == "content-length" {
length = value.parse().unwrap_or(0);
}
if name == "authorization" {
auth = Some(value);
}
}
}
let mut body = vec![0_u8; length];
if length > 0 {
reader.read_exact(&mut body).ok()?;
}
Some(Request { method, path, auth, body: String::from_utf8_lossy(&body).into_owned() })
}
/// Writes one answer of the stub.
fn write_answer(stream: &mut TcpStream, answer: &Answer) {
let reason = match answer.status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
_ => "Status",
};
let head = format!("HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", answer.status, reason, answer.body.len());
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(answer.body.as_bytes());
let _ = stream.flush();
}
/// A vault bound to the stub, with the registry and the session.
fn bound(name: &str, stub: &Stub) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
bind_folder(vault.path(), &stub.base, "v-1", TOKEN, "d-1", Some("u-1")).expect("bind the vault");
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let session = Session::open(vault.path()).expect("open the vault");
(reg, session, vault)
}
/// The code of a command error.
fn code(result: &Result<Value, Error>) -> String {
match result {
Err(Error::Command { source, .. }) => source.code.clone(),
other => panic!("expected a command error, got {other:?}"),
}
}
/// The message of a command error.
fn message(result: &Result<Value, Error>) -> String {
match result {
Err(Error::Command { source, .. }) => source.message.clone(),
other => panic!("expected a command error, got {other:?}"),
}
}
/// The vault list goes to the server with the token of the binding.
#[test]
fn the_vault_list_goes_to_the_server_with_the_token() {
let stub = Stub::spawn();
stub.route(
"GET",
"/v1/vaults",
200,
r#"[{"id":"v-1","name":"Notes","role":"owner","members":2,"bytes_used":10,"quota_bytes":null,"head_seq":7,"last_change_ms":1000}]"#,
);
let (reg, mut session, _vault) = bound("vaults", &stub);
let answer = reg.run("server.vaults", &mut session, &Value::Null).expect("server.vaults");
let vaults = answer.get("vaults").and_then(Value::as_array).expect("the vaults");
assert_eq!(vaults.len(), 1);
assert_eq!(vaults.first().and_then(|vault| vault.get("id")), Some(&json!("v-1")));
assert_eq!(vaults.first().and_then(|vault| vault.get("role")), Some(&json!("owner")));
assert_eq!(vaults.first().and_then(|vault| vault.get("members")), Some(&json!(2)));
let requests = stub.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests.first().map(|request| request.method.as_str()), Some("GET"));
assert_eq!(requests.first().map(|request| request.path.as_str()), Some("/v1/vaults"));
assert_eq!(requests.first().and_then(|request| request.auth.clone()), Some(format!("Bearer {TOKEN}")));
assert!(!answer.to_string().contains(TOKEN), "the token never reaches the answer");
}
/// Every request the commands make is the one the server expects.
#[test]
fn the_requests_are_the_ones_the_server_expects() {
let stub = Stub::spawn();
stub.route("POST", "/v1/vaults", 200, r#"{"vault_id":"v-2","name":"Second"}"#);
stub.route("PATCH", "/v1/vaults/v-2", 204, "");
stub.route("DELETE", "/v1/vaults/v-2", 204, "");
stub.route("GET", "/v1/vaults/v-1/members", 200, r#"[{"user_id":"u-2","name":"Ana","color":"red","role":"editor","last_seen_ms":5}]"#);
stub.route("PATCH", "/v1/vaults/v-1/members/u-2", 204, "");
stub.route("DELETE", "/v1/vaults/v-1/members/u-2", 204, "");
stub.route("POST", "/v1/vaults/v-1/invites", 200, r#"{"code":"abcd-efgh-ijkl","expires_in_s":172800}"#);
stub.route("GET", "/v1/vaults/v-1/invites", 200, r#"[{"id":"i-1","role":"editor","created_ms":1,"expires_ms":2}]"#);
stub.route("DELETE", "/v1/vaults/v-1/invites/i-1", 204, "");
stub.route("POST", "/v1/invites/accept", 200, r#"{"vault_id":"v-9","name":"Shared","role":"editor"}"#);
let (reg, mut session, _vault) = bound("requests", &stub);
let created = reg.run("server.create_vault", &mut session, &json!({ "name": "Second" })).expect("server.create_vault");
assert_eq!(created.get("vault").and_then(|vault| vault.get("id")), Some(&json!("v-2")));
reg.run("server.rename_vault", &mut session, &json!({ "id": "v-2", "name": "Renamed" })).expect("server.rename_vault");
reg.run("server.delete_vault", &mut session, &json!({ "id": "v-2" })).expect("server.delete_vault");
let members = reg.run("share.members", &mut session, &Value::Null).expect("share.members");
assert_eq!(members.get("members").and_then(Value::as_array).map(Vec::len), Some(1));
reg.run("share.set_role", &mut session, &json!({ "user": "u-2", "role": "viewer" })).expect("share.set_role");
reg.run("share.remove", &mut session, &json!({ "user": "u-2" })).expect("share.remove");
let invite = reg.run("share.invite", &mut session, &json!({ "role": "editor" })).expect("share.invite");
assert_eq!(invite.get("code"), Some(&json!("abcd-efgh-ijkl")));
let listed = reg.run("share.invites", &mut session, &Value::Null).expect("share.invites");
assert!(!listed.to_string().contains("abcd"), "the listing never carries a code");
reg.run("share.revoke_invite", &mut session, &json!({ "id": "i-1" })).expect("share.revoke_invite");
let requests = stub.requests();
let lines: Vec<(String, String)> = requests.iter().map(|request| (request.method.clone(), request.path.clone())).collect();
assert_eq!(
lines,
vec![
("POST".to_owned(), "/v1/vaults".to_owned()),
("PATCH".to_owned(), "/v1/vaults/v-2".to_owned()),
("DELETE".to_owned(), "/v1/vaults/v-2".to_owned()),
("GET".to_owned(), "/v1/vaults/v-1/members".to_owned()),
("PATCH".to_owned(), "/v1/vaults/v-1/members/u-2".to_owned()),
("DELETE".to_owned(), "/v1/vaults/v-1/members/u-2".to_owned()),
("POST".to_owned(), "/v1/vaults/v-1/invites".to_owned()),
("GET".to_owned(), "/v1/vaults/v-1/invites".to_owned()),
("DELETE".to_owned(), "/v1/vaults/v-1/invites/i-1".to_owned()),
]
);
let bodies: Vec<Value> =
requests.iter().map(|request| if request.body.is_empty() { Value::Null } else { serde_json::from_str(&request.body).expect("a JSON body") }).collect();
assert_eq!(bodies.first(), Some(&json!({ "name": "Second" })));
assert_eq!(bodies.get(1), Some(&json!({ "name": "Renamed" })));
assert_eq!(bodies.get(4), Some(&json!({ "role": "viewer" })));
assert_eq!(bodies.get(6), Some(&json!({ "role": "editor" })));
for request in &requests {
assert_eq!(request.auth.as_deref(), Some(format!("Bearer {TOKEN}").as_str()), "every request carries the token");
}
}
/// A role that is not offered never reaches the server.
#[test]
fn a_role_that_is_not_offered_never_reaches_the_server() {
let stub = Stub::spawn();
let (reg, mut session, _vault) = bound("roles", &stub);
for role in ["owner", "admin", "Owner", "", "editor "] {
let result = reg.run("share.set_role", &mut session, &json!({ "user": "u-2", "role": role }));
assert_eq!(code(&result), "invalid_value", "the role `{role}` must be refused");
let result = reg.run("share.invite", &mut session, &json!({ "role": role }));
assert_eq!(code(&result), "invalid_value", "the role `{role}` must be refused");
}
assert!(stub.requests().is_empty(), "no request was made");
}
/// The message of the server reaches the user, the token never does.
#[test]
fn the_message_of_the_server_reaches_the_user() {
let stub = Stub::spawn();
stub.route("GET", "/v1/vaults/v-1/members", 403, r#"{"error":"token is not valid for this vault"}"#);
let (reg, mut session, _vault) = bound("message", &stub);
let result = reg.run("share.members", &mut session, &Value::Null);
assert_eq!(code(&result), "share");
let text = message(&result);
assert!(text.contains("token is not valid for this vault"), "{text}");
assert!(text.contains("403"), "{text}");
assert!(!text.contains(TOKEN), "{text}");
}
/// An unbound vault says so instead of asking the server.
#[test]
fn an_unbound_vault_says_so() {
let stub = Stub::spawn();
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let vault = temp_vault("unbound");
let mut session = Session::open(vault.path()).expect("open the vault");
let result = reg.run("server.vaults", &mut session, &Value::Null);
assert_eq!(code(&result), "share");
assert!(message(&result).contains("not bound"), "{}", message(&result));
assert!(stub.requests().is_empty(), "no request was made");
}
/// The binding of a folder holds the vault and keeps the token away.
#[test]
fn the_binding_of_a_folder_holds_the_vault_and_keeps_the_token_away() {
let vault = temp_vault("binding");
bind_folder(vault.path(), "https://sync.example", "v-1", TOKEN, "d-1", Some("u-1")).expect("bind the folder");
let binding = std::fs::read_to_string(vault.path().join(".notescraft/sync/binding.json")).expect("the binding");
assert!(binding.contains("v-1"), "{binding}");
assert!(binding.contains("https://sync.example"), "{binding}");
assert!(!binding.contains(TOKEN), "the token never reaches the binding");
let credentials = vault.path().join(".notescraft/sync/credentials.json");
assert!(std::fs::read_to_string(&credentials).expect("the credentials").contains(TOKEN));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&credentials).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the token file is private");
}
}
/// A folder that is missing or is not a directory is refused.
#[test]
fn a_folder_that_is_not_a_folder_is_refused() {
let vault = temp_vault("folders");
assert!(bind_folder(&vault.path().join("nope"), "https://sync.example", "v-1", TOKEN, "d-1", None).is_err());
let file = vault.path().join("a-file");
std::fs::write(&file, b"x").expect("write the file");
assert!(bind_folder(&file, "https://sync.example", "v-1", TOKEN, "d-1", None).is_err());
assert!(!vault.path().join(".notescraft/sync/binding.json").exists(), "nothing was written");
}
/// Ids with a slash or a space are percent-encoded, never walked out of.
#[test]
fn ids_are_percent_encoded() {
let stub = Stub::spawn();
stub.route("PATCH", "/v1/vaults/v-1/members/u%2F1%202", 204, "");
let client = ServerClient::new(&stub.base, TOKEN);
client.set_role("v-1", "u/1 2", "editor").expect("set the role");
let requests = stub.requests();
assert_eq!(requests.first().map(|request| request.path.as_str()), Some("/v1/vaults/v-1/members/u%2F1%202"));
let body: Value = serde_json::from_str(&requests.first().expect("a request").body).expect("a JSON body");
assert_eq!(body, json!({ "role": "editor" }));
}
/// Hostile params are refused, never a panic.
#[test]
fn hostile_params_never_panic_a_share_command() {
let stub = Stub::spawn();
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let vault = temp_vault("hostile");
let mut session = Session::open(vault.path()).expect("open the vault");
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut panicked = Vec::new();
for id in [
"server.vaults",
"server.create_vault",
"server.rename_vault",
"server.delete_vault",
"server.connect",
"share.members",
"share.set_role",
"share.remove",
"share.invite",
"share.invites",
"share.revoke_invite",
"share.accept",
] {
for params in adversarial_params() {
if let Err(Error::Panicked(other)) = reg.run(id, &mut session, &params) {
panicked.push(other);
}
}
}
std::panic::set_hook(previous);
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
assert!(stub.requests().is_empty(), "a hostile param never reached the server");
}