Files
Marcos Paulo d7617f4351
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: server revisions in the Versions tab, collaboration research
- engine: ServerClient file_history, file_revision and restore; commands server.history, server.revision, server.restore (bound vault only, 5 MiB cap, no token in any answer)
- ui-egui: the Versions tab lists the server's revisions under the local snapshots with Compare (diff against the note) and a two-click Restore
- specs: collaboration research (browsed, 866 lines, 211 sources) and its ranked next features added to the collaboration task list

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n).

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

349 lines
15 KiB
Rust

//! The server file-history commands against a stub server: the requests they
//! make, the rows and bytes they return and the mistakes they surface — the
//! style of `share_devices.rs`, with more than one canned route.
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, Session, 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";
/// A canned answer: method, path, status and body.
type Route = (String, String, u16, Vec<u8>);
/// One request the stub received.
#[derive(Debug, Clone)]
struct Request {
/// The HTTP method.
method: String,
/// The path of the request line, query included.
path: String,
/// The `Authorization` header, when there was one.
auth: Option<String>,
/// The body of the request.
body: String,
}
/// A stub server that answers canned routes and remembers what it was asked.
struct Stub {
/// The base URL of the server.
base: String,
/// Every request, in order.
seen: Arc<Mutex<Vec<Request>>>,
/// The canned answers.
routes: Arc<Mutex<Vec<Route>>>,
/// Whether the listener thread should stop.
stop: Arc<AtomicBool>,
}
impl Stub {
/// Starts a stub on an ephemeral port; routes may be added afterwards.
fn spawn() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
let port = listener.local_addr().expect("addr").port();
let seen = Arc::new(Mutex::new(Vec::new()));
let routes = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
let thread = (Arc::clone(&routes), Arc::clone(&seen), Arc::clone(&stop));
std::thread::spawn(move || {
while !thread.2.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => serve_connection(stream, &thread.0, &thread.1),
Err(_) => std::thread::sleep(Duration::from_millis(2)),
}
}
});
Self { base: format!("http://127.0.0.1:{port}"), seen, routes, stop }
}
/// Adds a canned answer; `body` may be text or raw bytes.
fn route(&self, method: &str, path: &str, status: u16, body: impl Into<Vec<u8>>) {
self.routes.lock().expect("routes").push((method.to_owned(), path.to_owned(), status, body.into()));
}
/// 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);
}
}
/// Answers every request of one connection from the canned routes.
fn serve_connection(stream: TcpStream, routes: &Mutex<Vec<Route>>, seen: &Mutex<Vec<Request>>) {
// An accepted socket may inherit the listener's non-blocking mode on some
// platforms: a read racing the request bytes must not cut the connection.
let _ = stream.set_nonblocking(false);
let mut reader = BufReader::new(stream);
loop {
let Some(request) = read_request(&mut reader) else { return };
let (status, body) = routes
.lock()
.expect("routes")
.iter()
.find(|(method, path, _status, _body)| *method == request.method && *path == request.path)
.map_or_else(|| (404, b"{\"error\":\"no such route\"}".to_vec()), |(_method, _path, status, body)| (*status, body.clone()));
seen.lock().expect("seen").push(request);
if write_answer(&mut reader, status, &body).is_err() {
return;
}
}
}
/// Reads one request: method, path, headers, body.
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Request> {
let mut line = String::new();
if reader.read_line(&mut line).ok()? == 0 {
return None;
}
let mut parts = line.split_whitespace();
let method = parts.next()?.to_owned();
let path = parts.next()?.to_owned();
let mut length = 0_usize;
let mut auth = None;
loop {
let mut header = String::new();
if reader.read_line(&mut header).ok()? == 0 {
break;
}
let header = header.trim_end();
if header.is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':')
&& name.trim().eq_ignore_ascii_case("authorization")
{
auth = Some(value.trim().to_owned());
}
if let Some((name, value)) = header.split_once(':')
&& name.trim().eq_ignore_ascii_case("content-length")
{
length = value.trim().parse().unwrap_or(0);
}
}
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(reader: &mut BufReader<TcpStream>, status: u16, body: &[u8]) -> std::io::Result<()> {
let stream = reader.get_mut();
let head = format!("HTTP/1.1 {status} S\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len());
stream.write_all(head.as_bytes())?;
stream.write_all(body)?;
stream.flush()
}
/// 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:?}"),
}
}
/// A vault bound to `stub`, with the registry, the session and the vault guard.
fn bound(stub: &Stub, name: &str) -> (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)
}
#[test]
fn the_history_list_asks_the_server_and_parses_the_rows() {
let stub = Stub::spawn();
stub.route(
"GET",
"/v1/vaults/v-1/files-history/old%20note.md?limit=10",
200,
json!([
{ "seq": 3, "size": 12, "mtime_ms": 50, "editor_name": "Ana", "deleted": false, "recorded_ms": 60 },
{ "seq": 2, "size": 0, "deleted": true, "recorded_ms": 40 }
])
.to_string(),
);
let (reg, mut session, _vault) = bound(&stub, "history-list");
let answer = reg.run("server.history", &mut session, &json!({ "path": "old note.md", "limit": 10 })).expect("server.history");
let rows = answer.get("revisions").and_then(Value::as_array).expect("the revisions");
assert_eq!(rows.len(), 2);
assert_eq!(rows.first().and_then(|row| row.get("seq")), Some(&json!(3)));
assert_eq!(rows.first().and_then(|row| row.get("editor_name")), Some(&json!("Ana")));
let last = rows.get(1).and_then(|row| row.get("editor_name")).expect("the second row");
assert_eq!(last, &Value::Null, "a missing editor_name stays null");
assert_eq!(rows.get(1).and_then(|row| row.get("deleted")), Some(&json!(true)));
assert_eq!(rows.get(1).and_then(|row| row.get("recorded_ms")), Some(&json!(40)));
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/v-1/files-history/old%20note.md?limit=10"));
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: {answer}");
}
#[test]
fn the_default_limit_is_fifty_rows() {
let stub = Stub::spawn();
stub.route("GET", "/v1/vaults/v-1/files-history/n.md?limit=50", 200, "[]");
let (reg, mut session, _vault) = bound(&stub, "history-default");
reg.run("server.history", &mut session, &json!({ "path": "n.md" })).expect("server.history");
assert_eq!(stub.requests().first().map(|request| request.path.as_str()), Some("/v1/vaults/v-1/files-history/n.md?limit=50"));
}
#[test]
fn a_limit_outside_its_bounds_is_refused_before_a_request() {
let stub = Stub::spawn();
let (reg, mut session, _vault) = bound(&stub, "history-limit");
assert_eq!(code(&reg.run("server.history", &mut session, &json!({ "path": "n.md", "limit": 0 }))), "invalid_value");
assert_eq!(code(&reg.run("server.history", &mut session, &json!({ "path": "n.md", "limit": 201 }))), "invalid_value");
assert!(stub.requests().is_empty(), "no request was made");
}
#[test]
fn a_revision_returns_the_bytes_of_that_seq() {
let stub = Stub::spawn();
let mut bytes = vec![b'a'; 200];
bytes[0] = b'o';
bytes.extend_from_slice(b"l\xffd text");
let nul_at = bytes.len();
bytes.push(0);
stub.route("GET", "/v1/vaults/v-1/files/some%20dir/n.md?seq=3", 200, bytes.clone());
let (reg, mut session, _vault) = bound(&stub, "history-read");
let answer = reg.run("server.revision", &mut session, &json!({ "path": "some dir/n.md", "seq": 3 })).expect("server.revision");
let text = answer.get("text").and_then(Value::as_str).expect("the text");
assert!(text.contains('\u{fffd}'), "broken UTF-8 reads lossy: {text}");
assert!(text.contains("d text"), "the readable part survives");
assert!(text.contains("text"), "the readable part survives: {text}");
let requests = stub.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests.first().map(|request| request.path.as_str()), Some("/v1/vaults/v-1/files/some%20dir/n.md?seq=3"));
assert_eq!(requests.first().and_then(|request| request.auth.clone()), Some(format!("Bearer {TOKEN}")));
assert_eq!(nul_at, 208, "the byte layout of this fixture");
}
#[test]
fn heavy_nul_bytes_are_refused_as_binary() {
let stub = Stub::spawn();
let bytes = vec![b'a', 0, 0, 0, b'b', 0, 0, 0];
stub.route("GET", "/v1/vaults/v-1/files/n.md?seq=3", 200, bytes);
let (reg, mut session, _vault) = bound(&stub, "history-nul");
let result = reg.run("server.revision", &mut session, &json!({ "path": "n.md", "seq": 3 }));
assert_eq!(code(&result), "invalid_value");
assert!(message(&result).contains("binary"), "{}", message(&result));
}
#[test]
fn a_revision_too_big_to_show_is_refused() {
let stub = Stub::spawn();
stub.route("GET", "/v1/vaults/v-1/files/n.md?seq=3", 200, vec![b'x'; 5 * 1024 * 1024 + 2]);
let (reg, mut session, _vault) = bound(&stub, "history-huge");
let result = reg.run("server.revision", &mut session, &json!({ "path": "n.md", "seq": 3 }));
assert_eq!(code(&result), "share");
assert!(message(&result).contains("revision too large"), "{}", message(&result));
assert!(!message(&result).contains(TOKEN), "no token in the error");
}
#[test]
fn restore_posts_the_seq_of_the_chosen_revision() {
let stub = Stub::spawn();
stub.route("POST", "/v1/vaults/v-1/files-restore/folder/n.md", 201, json!({ "seq": 41, "size": 12 }).to_string());
let (reg, mut session, _vault) = bound(&stub, "history-restore");
let answer = reg.run("server.restore", &mut session, &json!({ "path": "folder/n.md", "seq": 3 })).expect("server.restore");
assert_eq!(answer.get("restored"), Some(&json!(true)));
assert_eq!(stub.requests().len(), 1, "exactly one POST");
assert_eq!(stub.requests().first().map(|request| request.method.as_str()), Some("POST"));
assert_eq!(stub.requests().first().map(|request| request.path.as_str()), Some("/v1/vaults/v-1/files-restore/folder/n.md"));
let sent = serde_json::from_str::<Value>(&stub.requests().first().expect("the POST").body).expect("JSON");
assert_eq!(sent.get("seq"), Some(&json!(3)), "exactly the chosen revision: {sent}");
}
#[test]
fn the_mistakes_of_the_server_reach_the_user() {
let stub = Stub::spawn();
stub.route("GET", "/v1/vaults/v-1/files-history/n.md?limit=50", 404, "{\"error\":\"no such path\"}");
stub.route("POST", "/v1/vaults/v-1/files-restore/n.md", 410, "{\"error\":\"the blob of that revision is gone\"}");
let (reg, mut session, _vault) = bound(&stub, "history-status");
let gone = reg.run("server.restore", &mut session, &json!({ "path": "n.md", "seq": 3 }));
assert_eq!(code(&gone), "share");
assert!(message(&gone).contains("410"), "{}", message(&gone));
assert!(message(&gone).contains("the blob of that revision is gone"), "{}", message(&gone));
let missing = reg.run("server.history", &mut session, &json!({ "path": "n.md" }));
assert_eq!(code(&missing), "share");
assert!(message(&missing).contains("no such path"), "{}", message(&missing));
}
#[test]
fn an_unbound_vault_reports_itself_before_any_request() {
let stub = Stub::spawn();
let vault = temp_vault("history-unbound");
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let mut session = Session::open(vault.path()).expect("open the vault");
for (id, params) in [
("server.history", json!({ "path": "n.md" })),
("server.revision", json!({ "path": "n.md", "seq": 3 })),
("server.restore", json!({ "path": "n.md", "seq": 3 })),
] {
let result = reg.run(id, &mut session, &params);
assert_eq!(code(&result), "share", "{id}");
assert!(message(&result).contains("not bound"), "{id}: {}", message(&result));
}
assert!(stub.requests().is_empty(), "no request was made");
}
#[test]
fn no_hostile_param_makes_the_history_commands_panic() {
let stub = Stub::spawn();
stub.route("GET", "/v1/vaults/v-1/files-history/n.md?limit=50", 200, "[]");
let (reg, mut session, _vault) = bound(&stub, "history-hostile");
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut panicked = Vec::new();
for id in ["server.history", "server.revision", "server.restore"] {
for params in notescraft_engine::adversarial_params() {
if let Err(Error::Panicked(other)) = reg.run(id, &mut session, &params) {
panicked.push((id, other));
}
}
}
std::panic::set_hook(previous);
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
}