eidetica/service/protocol.rs
1//! Wire protocol types for the Eidetica service.
2//!
3//! The protocol uses length-prefixed JSON frames over a Unix domain socket.
4//! Each frame is a 4-byte big-endian length followed by the JSON payload.
5//!
6//! ## Request shape
7//!
8//! `ServiceRequest` is a flat enum holding pre-authentication lifecycle messages
9//! (`TrustedLoginUser`, `TrustedLoginProve`), the pre-auth `GetInstanceMetadata`
10//! query, and an `AuthenticatedDb` wrapper that carries every storage operation
11//! (including any user-management writes against `_users`). The wrapper
12//! bundles the `(root_id, identity)` scope so the server can validate each
13//! op against the connection's session keyset and the target database's
14//! auth settings. Pre-auth verification of the session pubkey happens once at
15//! login via a challenge-response handshake.
16//!
17//! The login lifecycle is **trusted** in the sense that the daemon ships the
18//! user's encrypted credentials (salt + AES-GCM ciphertext) to anyone who can
19//! connect to the socket and asks for them. This is safe in the local-socket
20//! model — filesystem permissions on the socket already bound the caller set
21//! to processes that could read the underlying DB files directly. A network
22//! transport would need a different shape (PAKE: OPAQUE/SRP) so the server
23//! doesn't release the blob until the client proves password knowledge in a
24//! way that doesn't leak it. The `TrustedLogin*` naming is a load-bearing
25//! reminder of that assumption — see § Trusted login threat model in the
26//! Service Architecture doc.
27//!
28//! `AuthenticatedDb` requests carry the caller's `root_id`/`identity` and are
29//! gated per-tree by the daemon's permission check; clients populate these
30//! from the session established by the `TrustedLogin*` flow.
31
32use std::collections::BTreeMap;
33
34use serde::{Deserialize, Serialize};
35use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
36
37use crate::auth::crypto::PublicKey;
38use crate::auth::types::{Permission, SigKey};
39use crate::backend::InstanceMetadata;
40use crate::entry::{Entry, ID};
41use crate::service::error::ServiceError;
42use crate::user::UserInfo;
43
44/// Protocol version. Version 0 indicates an unstable protocol that may change
45/// without notice between releases.
46pub const PROTOCOL_VERSION: u32 = 0;
47
48/// Maximum frame size: 64 MiB.
49pub const MAX_FRAME_SIZE: u32 = 64 * 1024 * 1024;
50
51/// Handshake message sent by the client on connection.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Handshake {
54 pub protocol_version: u32,
55}
56
57/// Handshake acknowledgment sent by the server.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct HandshakeAck {
60 pub protocol_version: u32,
61}
62
63// ===========================================================================
64// Database-level wire API.
65//
66// Every storage operation rides this single op enum: the server runs the
67// `Database` layer on its local instance, so verify-on-read and the Verified
68// frontier are server-side by construction, and every op is intrinsically
69// (tree, store, identity)-scoped. Carried in `ServiceRequest::AuthenticatedDb`.
70// ===========================================================================
71
72/// Which projection of the DAG an op observes. Mirrors the `Database`
73/// read posture: a write's parent tips are the tips of the *same* projection
74/// the caller reads (see the Verification Model design doc).
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
76pub enum ReadScope {
77 /// Default-safe: only the maximal all-`Verified` ancestor-closed prefix.
78 #[default]
79 Verified,
80 /// Also include `Unverified` entries (`Failed` always dropped). The
81 /// caller explicitly opted in via `Database::allow_unverified()`.
82 AllowUnverified,
83}
84
85/// A CRDT store's materialized state on the wire. Concrete `Store<T>` typing
86/// stays client-side sugar over this; the cache path already ships
87/// `serde_json` bytes today, so this introduces no new representation.
88pub type WireCrdtValue = serde_json::Value;
89
90/// Everything a client needs to build **and sign** an entry locally without
91/// further round-trips. The client owns its keys, so signing stays
92/// client-side; only the inputs `Transaction::commit` reads from storage
93/// before signing travel here. Heights accompany each parent so the client
94/// computes entry height without a follow-up `GetEntry` per parent.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct TransactionContext {
97 /// Main-tree parent tips with their heights, in the caller's `scope`.
98 pub main_parents: Vec<(ID, u64)>,
99 /// Per-store parent tips (with heights) reachable from `main_parents`.
100 pub subtree_parents: BTreeMap<String, Vec<(ID, u64)>>,
101 /// `_settings` tips this transaction pins in signed metadata.
102 pub settings_tips: Vec<ID>,
103 /// Merged `_settings` state the entry is authored against (used to build
104 /// the auth settings the signature is validated under).
105 pub settings_value: WireCrdtValue,
106}
107
108/// Response for ComputeMergeState: lowest common ancestor + path to tips.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct MergeState {
111 pub merge_base: ID,
112 pub path: Vec<ID>,
113}
114
115/// Database-level operations the server runs on its local `Database`.
116///
117/// The target database (`root_id`) and identity claim travel in
118/// [`AuthenticatedDbRequest`]; the per-tree gate runs against `root_id`
119/// (Read for begin/get*, Write for submit, Admin-on-`_databases` for
120/// set-metadata) before dispatch.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub enum DatabaseOp {
123 /// Acquire everything needed to build+sign a transaction locally for the
124 /// given stores, with parents drawn from `scope`'s projection. Gate Read.
125 BeginTransaction {
126 stores: Vec<String>,
127 scope: ReadScope,
128 },
129 /// Submit a finished, client-signed entry. The server stores it
130 /// `Unverified` and runs its **own** verification pass — it never trusts
131 /// a submitted entry's claimed validity. Submit is *verification-gated,
132 /// not session-gated*: it requires only an authenticated connection, and
133 /// the per-tree permission gate is **not** applied (the server's
134 /// verification pass against the tree's pinned auth is the boundary). The
135 /// `required_permission()` value below is advisory only for this variant.
136 SubmitSignedEntry { entry: Box<Entry> },
137 /// The database's Verified-frontier tips (server runs `Database::snapshot`
138 /// on its local instance). Gate Read.
139 GetVerifiedTips,
140 /// Server-materialized merged state of an **unencrypted** store, against
141 /// the server's own Verified frontier. Gate Read.
142 GetStoreState { store: String },
143 /// Ordered (by subtree height), verified, opaque store entries reachable
144 /// from `tips` in `scope` — the universal primitive, incl. encrypted
145 /// stores (client decrypts+merges locally). Gate Read.
146 GetStoreEntries {
147 store: String,
148 tips: Vec<ID>,
149 scope: ReadScope,
150 },
151 /// Subtree tips reachable from given main-tree entry IDs.
152 /// Used by Transaction internals to discover store entries.
153 GetStoreTipsUpToEntries { store: String, up_to: Vec<ID> },
154
155 /// Lowest common ancestor + path to tip entries in a store DAG.
156 /// Fused to one RPC: the only caller always calls find_merge_base
157 /// then get_path_from_to in sequence.
158 ComputeMergeState { store: String, entry_ids: Vec<ID> },
159
160 /// Fetch a single entry by id (gated post-fetch by its owning tree). Gate
161 /// Read.
162 GetEntry { id: ID },
163
164 /// Look up a cached materialized CRDT state. Server returns the previously
165 /// `CacheCrdtState`-submitted blob for `(session user, root_id, key, store)`,
166 /// or `None` on miss. Gate Read.
167 ///
168 /// Used by [`RemoteBackend::get_cached_crdt_state`](crate::instance::backend::RemoteBackend)
169 /// as the second tier of a two-level cache: the client first checks its own
170 /// per-connection LRU, then falls back to this RPC. The daemon's cache is
171 /// the cross-session source of truth.
172 GetCachedCrdtState { store: String, key: ID },
173
174 /// Stash a client-computed materialized CRDT state for `(session user,
175 /// root_id, key, store)`. Gate Read.
176 ///
177 /// **Per-user trust model**: the daemon stores whatever bytes the
178 /// authenticated user sends, scoped to their `user_uuid`. The blob is
179 /// **opaque** to the daemon — ciphertext for encrypted stores, plaintext
180 /// for plain ones — and the daemon performs no verification of the
181 /// merge result. The trust boundary is the same one the client would have
182 /// with a local-only cache: only the submitting user can poison their
183 /// future reads on this slot.
184 ///
185 /// **Tip-based natural expiry**: keys are derived from tip sets (see
186 /// `create_merge_cache_id`), so an entry whose tip set has advanced is
187 /// simply unreachable — future reads miss against a fresh key. Stale
188 /// entries fall out of the LRU under memory pressure rather than via
189 /// explicit invalidation.
190 CacheCrdtState {
191 store: String,
192 key: ID,
193 blob: Vec<u8>,
194 },
195
196 /// Rewrite the daemon's instance metadata (system-DB pointers). Gated by
197 /// `Admin` on `_databases` (a daemon-global system tree, resolved
198 /// server-side — *not* the request's `root_id`), so the per-tree gate is
199 /// special-cased for this variant in the dispatcher. Boxed to keep the
200 /// enum's stack footprint small — `InstanceMetadata` dominates its size.
201 SetInstanceMetadata { metadata: Box<InstanceMetadata> },
202}
203
204impl DatabaseOp {
205 /// Minimum permission the caller needs against the target database.
206 ///
207 /// Only `SubmitSignedEntry` mutates; everything else is a read. Every
208 /// read variant is tree-scoped via the request's `root_id`, so the
209 /// per-tree gate always runs for reads — there is no tree-less
210 /// fall-through. `SubmitSignedEntry` is the exception: the server skips
211 /// the per-tree gate for submit and relies on its own verification pass,
212 /// so the `Write(0)` returned here is advisory only for that variant
213 /// (kept for completeness / non-submit callers that inspect it).
214 pub fn required_permission(&self) -> Permission {
215 match self {
216 DatabaseOp::SubmitSignedEntry { .. } => Permission::Write(0),
217 // Gated against `_databases`, not the request's `root_id`; the
218 // dispatcher special-cases this so the value here is advisory.
219 DatabaseOp::SetInstanceMetadata { .. } => Permission::Admin(0),
220 DatabaseOp::GetStoreTipsUpToEntries { .. } => Permission::Read,
221 DatabaseOp::ComputeMergeState { .. } => Permission::Read,
222 _ => Permission::Read,
223 }
224 }
225}
226
227/// Payload of an `AuthenticatedDb` service request.
228///
229/// Bundles the database scope (`root_id`) and identity claim (`identity`) with
230/// the [`DatabaseOp`] to run. Boxed inside `ServiceRequest::AuthenticatedDb` to
231/// keep the top-level enum's stack footprint flat — `SigKey` and
232/// `DatabaseOp::SubmitSignedEntry` are large.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct AuthenticatedDbRequest {
235 /// Root entry of the database this op targets (auth-settings lookup +
236 /// the implicit tree scope every `DatabaseOp` carries by construction).
237 pub root_id: ID,
238 /// Identity claim; verified against the connection's session keyset
239 /// before dispatch.
240 pub identity: SigKey,
241 /// Database operation to execute.
242 pub op: DatabaseOp,
243}
244
245/// Top-level request from client to server.
246///
247/// The shape is intentionally flat: pre-auth lifecycle and queries sit beside
248/// the `AuthenticatedDb` wrapper rather than under a nested enum. This makes the
249/// pre-auth surface visible at a glance and keeps the server's dispatch
250/// branches symmetric.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub enum ServiceRequest {
253 // === Pre-auth: trusted login handshake ===
254 /// Step 1 of the trusted login flow. Client names a user; server responds
255 /// with a `TrustedLoginChallenge` carrying random bytes the client must
256 /// sign. The "Trusted" qualifier is a load-bearing reminder that this flow
257 /// assumes the caller is already trusted by the socket's filesystem
258 /// permissions — over a network transport this would need PAKE instead.
259 TrustedLoginUser { username: String },
260 /// Step 2 of the trusted login flow. Client returns a signature over the
261 /// challenge from `TrustedLoginUser`, computed with the user's root key.
262 /// Server verifies against the stored pubkey and, on success, marks the
263 /// connection authenticated.
264 TrustedLoginProve { signature: Vec<u8> },
265
266 // === Pre-auth: queries safe before login ===
267 /// Fetch the server's instance metadata (including device id). Used by
268 /// `Instance::connect` during the handshake to establish server identity.
269 GetInstanceMetadata,
270
271 // === Post-auth: extend the connection's session keyset ===
272 /// Step 1 of registering an additional pubkey on an already-authenticated
273 /// connection. The client names a `pubkey`; the server issues a random
274 /// challenge bound to that pubkey. The pubkey is added to the keyset only
275 /// after the client returns a valid signature in `SessionKeyRegister`.
276 ///
277 /// Session-key registration extends the connection's identity from the
278 /// single `login_pubkey` (from `TrustedLogin*`) to a *set* of pubkeys the
279 /// client has proven possession of. Per-tree reads gate against this set,
280 /// so a user can drive operations on databases authored by any of their
281 /// per-DB keys without re-authenticating the whole connection.
282 SessionKeyChallenge { pubkey: PublicKey },
283 /// Step 2 of registering an additional pubkey. Carries a signature over
284 /// the challenge issued by the matching `SessionKeyChallenge`. Server
285 /// verifies the signature with the named `pubkey`; on success the pubkey
286 /// joins the connection's session keyset and the challenge is consumed.
287 SessionKeyRegister {
288 pubkey: PublicKey,
289 signature: Vec<u8>,
290 },
291
292 // === Authenticated wrapper for every storage operation ===
293 /// All storage ops travel inside this wrapper. The inner
294 /// `AuthenticatedDbRequest` carries `(root_id, identity, op)` and is boxed
295 /// to keep the enum's discriminated size compact.
296 AuthenticatedDb(Box<AuthenticatedDbRequest>),
297}
298
299/// Response from server to client.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub enum ServiceResponse {
302 /// Single entry
303 Entry(Entry),
304 /// Multiple entries
305 Entries(Vec<Entry>),
306 /// Multiple IDs
307 Ids(Vec<ID>),
308 /// Success with no data
309 Ok,
310 /// Transaction-build context (response to `DatabaseOp::BeginTransaction`).
311 TransactionContext(TransactionContext),
312 /// Materialized CRDT store state (response to `DatabaseOp::GetStoreState`).
313 CrdtValue(WireCrdtValue),
314 /// Merge state: lowest common ancestor + path to tips (response to
315 /// `DatabaseOp::ComputeMergeState`).
316 MergeState(MergeState),
317 /// Optional instance metadata
318 InstanceMetadata(Option<InstanceMetadata>),
319 /// Optional cached CRDT state blob (response to
320 /// `DatabaseOp::GetCachedCrdtState`). `None` on cache miss; the daemon
321 /// does not synthesize a value, so the client falls back to recomputing
322 /// from store entries.
323 CachedCrdtState(Option<Vec<u8>>),
324 /// Error response
325 Error(ServiceError),
326 /// Challenge bytes returned in response to `TrustedLoginUser`, plus the
327 /// user's full record so the client can derive the password→key, decrypt
328 /// the root signing key locally, sign the challenge in a single
329 /// round-trip, and then build the `User` session from data the daemon
330 /// already returned — no second wire read of `_users` is required.
331 ///
332 /// `user_info.credentials` carries the (encrypted) root private key, its
333 /// `KeyStorage` envelope (algorithm/ciphertext/nonce for password-protected
334 /// users, raw `PrivateKey` for passwordless users), and the Argon2id salt
335 /// when password-protected. The non-credential fields (user_database_id,
336 /// status, timestamps) are what `User::new` consumes after the proof
337 /// step succeeds. See § Trusted login threat model in the Service
338 /// Architecture doc for why this is safe to ship to anyone who can
339 /// reach the socket.
340 TrustedLoginChallenge {
341 challenge: Vec<u8>,
342 user_uuid: String,
343 user_info: UserInfo,
344 },
345 /// Trusted login succeeded; the connection is now authenticated.
346 TrustedLoginOk,
347 /// Challenge bytes returned in response to `SessionKeyChallenge`. The
348 /// client signs these with the named pubkey's private key and returns the
349 /// signature in `SessionKeyRegister`.
350 SessionKeyChallenge { challenge: Vec<u8> },
351}
352
353/// Write a length-prefixed JSON frame to an async writer.
354pub async fn write_frame<W: AsyncWrite + Unpin, T: Serialize>(
355 writer: &mut W,
356 value: &T,
357) -> crate::Result<()> {
358 let payload = serde_json::to_vec(value)?;
359 let len = payload.len() as u32;
360 if len > MAX_FRAME_SIZE {
361 return Err(crate::Error::Io(std::io::Error::new(
362 std::io::ErrorKind::InvalidData,
363 format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
364 )));
365 }
366 writer.write_all(&len.to_be_bytes()).await?;
367 writer.write_all(&payload).await?;
368 writer.flush().await?;
369 Ok(())
370}
371
372/// Read a length-prefixed JSON frame from an async reader.
373///
374/// Returns `None` on clean EOF (connection closed).
375pub async fn read_frame<R: AsyncRead + Unpin, T: for<'de> Deserialize<'de>>(
376 reader: &mut R,
377) -> crate::Result<Option<T>> {
378 let mut len_buf = [0u8; 4];
379 match reader.read_exact(&mut len_buf).await {
380 Ok(_) => {}
381 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
382 Err(e) => return Err(e.into()),
383 }
384 let len = u32::from_be_bytes(len_buf);
385 if len > MAX_FRAME_SIZE {
386 return Err(crate::Error::Io(std::io::Error::new(
387 std::io::ErrorKind::InvalidData,
388 format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
389 )));
390 }
391 let mut payload = vec![0u8; len as usize];
392 reader.read_exact(&mut payload).await?;
393 let value = serde_json::from_slice(&payload)?;
394 Ok(Some(value))
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 // Helper to make a simple entry for testing
402 fn test_id() -> ID {
403 ID::from_bytes("test-entry-id")
404 }
405
406 fn wrap(op: DatabaseOp) -> ServiceRequest {
407 ServiceRequest::AuthenticatedDb(Box::new(AuthenticatedDbRequest {
408 root_id: ID::default(),
409 identity: SigKey::default(),
410 op,
411 }))
412 }
413
414 /// Extract the inner `DatabaseOp` from a deserialised request, panicking if
415 /// the variant isn't `AuthenticatedDb`.
416 fn unwrap_op(req: ServiceRequest) -> DatabaseOp {
417 match req {
418 ServiceRequest::AuthenticatedDb(inner) => inner.op,
419 other => panic!("expected AuthenticatedDb, got {other:?}"),
420 }
421 }
422
423 #[test]
424 fn test_handshake_serde() {
425 let h = Handshake {
426 protocol_version: PROTOCOL_VERSION,
427 };
428 let json = serde_json::to_string(&h).unwrap();
429 let h2: Handshake = serde_json::from_str(&json).unwrap();
430 assert_eq!(h2.protocol_version, PROTOCOL_VERSION);
431 }
432
433 #[test]
434 fn test_handshake_ack_serde() {
435 let h = HandshakeAck {
436 protocol_version: PROTOCOL_VERSION,
437 };
438 let json = serde_json::to_string(&h).unwrap();
439 let h2: HandshakeAck = serde_json::from_str(&json).unwrap();
440 assert_eq!(h2.protocol_version, PROTOCOL_VERSION);
441 }
442
443 #[test]
444 fn test_request_get_entry_serde() {
445 let req = wrap(DatabaseOp::GetEntry { id: test_id() });
446 let json = serde_json::to_string(&req).unwrap();
447 let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
448 match unwrap_op(req2) {
449 DatabaseOp::GetEntry { id } => assert_eq!(id, test_id()),
450 _ => panic!("wrong variant"),
451 }
452 }
453
454 #[test]
455 fn test_request_get_instance_metadata_serde() {
456 let req = ServiceRequest::GetInstanceMetadata;
457 let json = serde_json::to_string(&req).unwrap();
458 let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
459 assert!(matches!(req2, ServiceRequest::GetInstanceMetadata));
460 }
461
462 #[test]
463 fn test_request_trusted_login_user_serde() {
464 let req = ServiceRequest::TrustedLoginUser {
465 username: "alice".to_string(),
466 };
467 let json = serde_json::to_string(&req).unwrap();
468 let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
469 match req2 {
470 ServiceRequest::TrustedLoginUser { username } => assert_eq!(username, "alice"),
471 _ => panic!("wrong variant"),
472 }
473 }
474
475 #[test]
476 fn test_request_trusted_login_prove_serde() {
477 let req = ServiceRequest::TrustedLoginProve {
478 signature: b"sig-bytes".to_vec(),
479 };
480 let json = serde_json::to_string(&req).unwrap();
481 let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
482 match req2 {
483 ServiceRequest::TrustedLoginProve { signature } => assert_eq!(signature, b"sig-bytes"),
484 _ => panic!("wrong variant"),
485 }
486 }
487
488 #[test]
489 fn test_response_ok_serde() {
490 let resp = ServiceResponse::Ok;
491 let json = serde_json::to_string(&resp).unwrap();
492 let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
493 assert!(matches!(resp2, ServiceResponse::Ok));
494 }
495
496 #[test]
497 fn test_response_ids_serde() {
498 let resp = ServiceResponse::Ids(vec![test_id(), ID::from_bytes("other")]);
499 let json = serde_json::to_string(&resp).unwrap();
500 let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
501 match resp2 {
502 ServiceResponse::Ids(ids) => {
503 assert_eq!(ids.len(), 2);
504 assert_eq!(ids[0], test_id());
505 }
506 _ => panic!("wrong variant"),
507 }
508 }
509
510 #[test]
511 fn test_response_error_serde() {
512 let se = ServiceError {
513 module: "backend".to_string(),
514 kind: "EntryNotFound".to_string(),
515 message: "Entry not found: abc".to_string(),
516 };
517 let resp = ServiceResponse::Error(se);
518 let json = serde_json::to_string(&resp).unwrap();
519 let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
520 match resp2 {
521 ServiceResponse::Error(e) => {
522 assert_eq!(e.module, "backend");
523 assert_eq!(e.kind, "EntryNotFound");
524 }
525 _ => panic!("wrong variant"),
526 }
527 }
528
529 #[test]
530 fn test_response_instance_metadata_none_serde() {
531 let resp = ServiceResponse::InstanceMetadata(None);
532 let json = serde_json::to_string(&resp).unwrap();
533 let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
534 assert!(matches!(resp2, ServiceResponse::InstanceMetadata(None)));
535 }
536
537 #[test]
538 fn test_response_trusted_login_challenge_serde() {
539 use crate::auth::crypto::generate_keypair;
540 use crate::user::{KeyStorage, UserCredentials, UserInfo, UserStatus};
541
542 let (_signing, pubkey) = generate_keypair();
543 let user_info = UserInfo {
544 username: "alice".to_string(),
545 user_database_id: ID::from_bytes("alice-db"),
546 credentials: UserCredentials {
547 root_key_id: pubkey.clone(),
548 root_key: KeyStorage::Encrypted {
549 algorithm: "aes-256-gcm".to_string(),
550 ciphertext: b"ct".to_vec(),
551 nonce: b"123456789012".to_vec(),
552 },
553 password_salt: Some("salt-string".to_string()),
554 },
555 created_at: 1_700_000_000,
556 status: UserStatus::Active,
557 };
558
559 let resp = ServiceResponse::TrustedLoginChallenge {
560 challenge: b"random-bytes".to_vec(),
561 user_uuid: "uuid-alice".to_string(),
562 user_info: user_info.clone(),
563 };
564 let json = serde_json::to_string(&resp).unwrap();
565 let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
566 match resp2 {
567 ServiceResponse::TrustedLoginChallenge {
568 challenge,
569 user_uuid,
570 user_info: ui2,
571 } => {
572 assert_eq!(challenge, b"random-bytes");
573 assert_eq!(user_uuid, "uuid-alice");
574 assert_eq!(ui2.username, user_info.username);
575 assert_eq!(ui2.user_database_id, user_info.user_database_id);
576 assert_eq!(ui2.credentials.root_key_id, pubkey);
577 assert_eq!(
578 ui2.credentials.password_salt.as_deref(),
579 Some("salt-string")
580 );
581 }
582 _ => panic!("wrong variant"),
583 }
584 }
585
586 #[test]
587 fn test_response_trusted_login_ok_serde() {
588 let resp = ServiceResponse::TrustedLoginOk;
589 let json = serde_json::to_string(&resp).unwrap();
590 let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
591 assert!(matches!(resp2, ServiceResponse::TrustedLoginOk));
592 }
593
594 #[tokio::test]
595 async fn test_frame_eof_returns_none() {
596 // Use a real Unix socket pair for proper EOF semantics
597 let dir = tempfile::tempdir().unwrap();
598 let sock_path = dir.path().join("eof-test.sock");
599 let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
600
601 let client = tokio::net::UnixStream::connect(&sock_path).await.unwrap();
602 let (server_stream, _) = listener.accept().await.unwrap();
603
604 // Drop the server stream to close the connection
605 drop(server_stream);
606
607 let (mut reader, _writer) = tokio::io::split(client);
608 let result: crate::Result<Option<ServiceRequest>> = read_frame(&mut reader).await;
609 assert!(result.unwrap().is_none());
610 }
611
612 #[tokio::test]
613 async fn test_frame_max_size_rejection_on_write() {
614 let (client, _server) = tokio::io::duplex(1024);
615 let (_read, mut write) = tokio::io::split(client);
616
617 // Create a payload that's too large
618 let huge_string = "x".repeat(MAX_FRAME_SIZE as usize + 1);
619 let result = write_frame(&mut write, &huge_string).await;
620 assert!(result.is_err());
621 }
622
623 #[tokio::test]
624 async fn test_frame_max_size_rejection_on_read() {
625 let (client, server) = tokio::io::duplex(1024 * 1024);
626 let (mut client_read, _client_write) = tokio::io::split(client);
627 let (_server_read, mut server_write) = tokio::io::split(server);
628
629 // Write a fake frame header with size > MAX_FRAME_SIZE from the server end
630 let fake_len = MAX_FRAME_SIZE + 1;
631 tokio::spawn(async move {
632 server_write
633 .write_all(&fake_len.to_be_bytes())
634 .await
635 .unwrap();
636 });
637
638 let result: crate::Result<Option<ServiceRequest>> = read_frame(&mut client_read).await;
639 assert!(result.is_err());
640 }
641}