Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

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}