9bd7dc931e
- 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>
218 lines
8.7 KiB
Rust
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, ¶ms) {
|
|
panicked.push(other);
|
|
}
|
|
}
|
|
std::panic::set_hook(previous);
|
|
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
|
|
}
|