Files
Marcos Paulo 9bd7dc931e
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: chunked resumable uploads in the sync driver and the phone sync sheet
- engine: Remote gains missing_chunks, put_chunk and put_chunked; upload_chunked streams a big file in 4 MiB chunks, sends only the chunks the server lacks, retries with backoff and reports progress (SyncStatus.transfer); the scan hashes by streaming and skips only files over 1 GiB
- engine: server.devices command and a sync.attachments option (non-Markdown files are skipped when it is off)
- ui-egui: the sync pill opens a bottom sheet on a phone-width window (status, devices, conflicts row, options, Sync now); wider windows keep Settings > Sync
- tests: chunk resume against the real server, 6 driven sheet tests, devices stub tests

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 08:57:07 -03:00

218 lines
8.7 KiB
Rust

//! The `server.devices` command against a stub server: the request it makes,
//! the rows it lists and the secrets it never carries.
//!
//! The stub is a plain TCP listener that answers one canned JSON and remembers
//! the request line, like the stub in `share_commands.rs`, kept smaller here
//! because the command needs one route.
mod common;
use std::io::{BufRead, BufReader, 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";
/// 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>,
}
/// A stub server: one canned answer, the requests it saw.
struct Stub {
/// The base URL of the server.
base: String,
/// Every request the stub received.
seen: Arc<Mutex<Vec<Request>>>,
/// Whether the listener thread should stop.
stop: Arc<AtomicBool>,
}
impl Stub {
/// Starts a stub on an ephemeral port that answers one route.
fn spawn(status: u16, body: &str) -> 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<Mutex<Vec<Request>>> = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
let head = format!("HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len());
let thread_seen = Arc::clone(&seen);
let thread_stop = Arc::clone(&stop);
std::thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let Ok(mut writer) = stream.try_clone() else { continue };
let mut reader = BufReader::new(stream);
if let Some(request) = read_request(&mut reader) {
thread_seen.lock().expect("seen").push(request);
let _ = writer.write_all(head.as_bytes());
let _ = writer.flush();
}
}
Err(_) => std::thread::sleep(Duration::from_millis(2)),
}
}
});
Self { base: format!("http://127.0.0.1:{port}"), seen, stop }
}
/// 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: method, path, the authorization header; no body is expected.
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;
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(':')
&& name.trim().eq_ignore_ascii_case("authorization")
{
auth = Some(value.trim().to_owned());
}
}
Some(Request { method, path, auth })
}
/// 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)
}
/// 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:?}"),
}
}
/// `server.devices` asks the server with the token and lists its rows.
#[test]
fn the_device_list_goes_to_the_server_with_the_token() {
let stub = Stub::spawn(
200,
r#"[{"id":"d-1","name":"Pixel 9","created_ms":100,"last_seen_ms":null,"current":true},{"id":"d-2","name":"Laptop","created_ms":50,"last_seen_ms":60,"current":false}]"#,
);
let (reg, mut session, _vault) = bound(&stub, "devices");
let answer = reg.run("server.devices", &mut session, &Value::Null).expect("server.devices");
let devices = answer.get("devices").and_then(Value::as_array).expect("the devices");
assert_eq!(devices.len(), 2);
assert_eq!(devices.first().and_then(|device| device.get("id")), Some(&json!("d-1")));
assert_eq!(devices.first().and_then(|device| device.get("name")), Some(&json!("Pixel 9")));
assert_eq!(devices.first().and_then(|device| device.get("current")), Some(&json!(true)));
assert_eq!(devices.first().and_then(|device| device.get("last_seen_ms")), Some(&Value::Null));
assert_eq!(devices.get(1).and_then(|device| device.get("last_seen_ms")), Some(&json!(60)));
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/devices"));
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 no_token_reaches_the_answer_or_the_rows() {
let stub = Stub::spawn(200, r#"[{"id":"d-2","name":"Laptop","created_ms":50,"last_seen_ms":60,"current":false}]"#);
let (reg, mut session, _vault) = bound(&stub, "no-token");
let answer = reg.run("server.devices", &mut session, &Value::Null).expect("server.devices");
assert!(!answer.to_string().contains(TOKEN), "{}", answer);
assert!(!answer.to_string().contains("token"), "{}", answer);
assert!(!answer.to_string().contains("secret"), "{}", answer);
}
/// A vault that is not bound says so instead of asking the server.
#[test]
fn an_unbound_vault_reports_not_bound() {
let stub = Stub::spawn(200, "[]");
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let vault = temp_vault("devices-unbound");
let mut session = Session::open(vault.path()).expect("open the vault");
let result = reg.run("server.devices", &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 message of the server reaches the user, the token never does.
#[test]
fn the_server_answer_refuses_like_the_others() {
let stub = Stub::spawn(401, r#"{"error":"token is not valid for this vault"}"#);
let (reg, mut session, _vault) = bound(&stub, "devices-401");
let result = reg.run("server.devices", &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(TOKEN), "{text}");
}
/// `server.devices` never panics on hostile params, and asks nothing.
#[test]
fn hostile_params_never_panic_the_devices_command() {
let stub = Stub::spawn(200, "[]");
let (reg, mut session, _vault) = bound(&stub, "devices-hostile");
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut panicked = Vec::new();
for params in notescraft_engine::adversarial_params() {
if let Err(Error::Panicked(other)) = reg.run("server.devices", &mut session, &params) {
panicked.push(other);
}
}
std::panic::set_hook(previous);
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
}