fix(daemon): correct the published contract and harden the control socket

A review pass over the diff found seventeen confirmed defects. The worst was
the contract itself: every method's parameters were published as a single
descriptor named `params`, so a client generated from openrpc.json sent
{"params":{"params":{…}}} and every call failed -32602. Parameters are now
published per field, with a test asserting the published names are the keys
the daemon actually deserializes.

Security and lifecycle:
- download.start no longer lets a caller name a directory, which was an
  arbitrary write on the daemon's host.
- The Unix socket is bound under a temporary name and renamed into place; it
  was briefly connectable by any local user between bind and chmod, and on
  that transport connecting is authenticating.
- Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and
  connections are capped, so an unauthenticated peer cannot exhaust memory or
  threads.
- A read error no longer returns past the hub unsubscribe, which leaked a
  subscriber and two threads per occurrence.
- A client whose event queue fills is now actually disconnected. It used to be
  dropped from the fan-out and left connected, working on but blind to every
  event after — the documentation already promised otherwise.
- A client whose daemon vanishes mid-transfer is told at once instead of
  waiting out its full timeout and reporting a misleading "gave up".

Contract accuracy: errors carry an explicit null id (a missing one reads as a
notification and gets dropped), a second auth answers with the published
shape, and nullable fields are sent rather than omitted so a generated client
sees one shape.

Also splits proto.rs and server.rs, whose concerns had diverged, and applies
the review's simplifications.
This commit is contained in:
Michel de Graaf
2026-07-27 11:38:46 +02:00
parent a69f52c657
commit 41307d0510
22 changed files with 2528 additions and 1942 deletions
+9 -6
View File
@@ -190,7 +190,7 @@ pub fn run(
let downloads = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
restore(ctx, &store, session.client.as_ref(), &hub, &downloads);
let rooms = restore(ctx, &store, session.client.as_ref(), &hub, &downloads);
let daemon = Arc::new(Daemon {
session: Arc::clone(&session.client),
@@ -202,7 +202,8 @@ pub fn run(
token,
started: Instant::now(),
stop: Arc::clone(&stop),
rooms: Mutex::new(store.load_rooms()),
open: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
rooms: Mutex::new(rooms),
});
let drainer = {
@@ -263,7 +264,7 @@ fn restore(
session: &dyn SessionApi,
hub: &Hub,
downloads: &Mutex<Vec<PendingDownload>>,
) {
) -> Vec<String> {
hub.restore(
store
.load_messages()
@@ -277,8 +278,9 @@ fn restore(
.collect(),
);
for room in store.load_rooms() {
if let Err(e) = session.join_room(&room) {
let rooms = store.load_rooms();
for room in &rooms {
if let Err(e) = session.join_room(room) {
ctx.out.warn(&format!("cannot rejoin {room}: {e}"));
}
}
@@ -289,7 +291,7 @@ fn restore(
.filter(|download| !download.completed)
.collect();
if unfinished.is_empty() {
return;
return rooms;
}
ctx.out
.status(&format!("resuming {} transfers", unfinished.len()));
@@ -314,6 +316,7 @@ fn restore(
.warn(&format!("cannot resume {}: {e}", download.filename)),
}
}
rooms
}
/// Write daemon-owned state back. Best-effort: a state file that cannot be