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::instance::WriteSource;
42use crate::service::error::ServiceError;
43use crate::snapshot::Snapshot;
44use crate::user::UserInfo;
45
46/// Protocol version. Version 0 indicates an unstable protocol that may change
47/// without notice between releases.
48pub const PROTOCOL_VERSION: u32 = 0;
49
50/// Maximum frame size: 64 MiB.
51pub const MAX_FRAME_SIZE: u32 = 64 * 1024 * 1024;
52
53/// Handshake message sent by the client on connection.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Handshake {
56    pub protocol_version: u32,
57}
58
59/// Handshake acknowledgment sent by the server.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct HandshakeAck {
62    pub protocol_version: u32,
63}
64
65// ===========================================================================
66// Database-level wire API.
67//
68// Every storage operation rides this single op enum: the server runs the
69// `Database` layer on its local instance, so verify-on-read and the Verified
70// frontier are server-side by construction, and every op is intrinsically
71// (tree, store, identity)-scoped. Carried in `ServiceRequest::AuthenticatedDb`.
72// ===========================================================================
73
74/// Which projection of the DAG an op observes. Mirrors the `Database`
75/// read posture: a write's parent tips are the tips of the *same* projection
76/// the caller reads (see the Verification Model design doc).
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
78pub enum ReadScope {
79    /// Default-safe: only the maximal all-`Verified` ancestor-closed prefix.
80    #[default]
81    Verified,
82    /// Also include `Unverified` entries (`Failed` always dropped). The
83    /// caller explicitly opted in via `Database::allow_unverified()`.
84    AllowUnverified,
85}
86
87/// A CRDT store's materialized state on the wire. Concrete `Store<T>` typing
88/// stays client-side sugar over this; the cache path already ships
89/// `serde_json` bytes today, so this introduces no new representation.
90pub type WireCrdtValue = serde_json::Value;
91
92/// Everything a client needs to build **and sign** an entry locally without
93/// further round-trips. The client owns its keys, so signing stays
94/// client-side; only the inputs `Transaction::commit` reads from storage
95/// before signing travel here. Heights accompany each parent so the client
96/// computes entry height without a follow-up `GetEntry` per parent.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct TransactionContext {
99    /// Main-tree parent tips with their heights, in the caller's `scope`.
100    pub main_parents: Vec<(ID, u64)>,
101    /// Per-store parent tips (with heights) reachable from `main_parents`.
102    pub subtree_parents: BTreeMap<String, Vec<(ID, u64)>>,
103    /// `_settings` tips this transaction pins in signed metadata.
104    pub settings_tips: Vec<ID>,
105    /// Merged `_settings` state the entry is authored against (used to build
106    /// the auth settings the signature is validated under).
107    pub settings_value: WireCrdtValue,
108}
109
110/// Response for ComputeMergeState: lowest common ancestor + path to tips.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct MergeState {
113    pub merge_base: ID,
114    pub path: Vec<ID>,
115}
116
117/// Database-level operations the server runs on its local `Database`.
118///
119/// The target database (`root_id`) and identity claim travel in
120/// [`AuthenticatedDbRequest`]; the per-tree gate runs against `root_id`
121/// (Read for begin/get*, Write for submit, Admin-on-`_databases` for
122/// set-metadata) before dispatch.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum DatabaseOp {
125    /// Acquire everything needed to build+sign a transaction locally for the
126    /// given stores, with parents drawn from `scope`'s projection. Gate Read.
127    BeginTransaction {
128        stores: Vec<String>,
129        scope: ReadScope,
130    },
131    /// Submit a finished, client-signed entry. The server stores it
132    /// `Unverified` and runs its **own** verification pass — it never trusts
133    /// a submitted entry's claimed validity. Submit is *verification-gated,
134    /// not session-gated*: it requires only an authenticated connection, and
135    /// the per-tree permission gate is **not** applied (the server's
136    /// verification pass against the tree's pinned auth is the boundary). The
137    /// `required_permission()` value below is advisory only for this variant.
138    SubmitSignedEntry { entry: Box<Entry> },
139    /// The database's Verified-frontier tips (server runs `Database::snapshot`
140    /// on its local instance). Gate Read.
141    GetVerifiedTips,
142    /// Server-materialized merged state of an **unencrypted** store, against
143    /// the server's own Verified frontier. Gate Read.
144    GetStoreState { store: String },
145    /// Ordered (by subtree height), verified, opaque store entries reachable
146    /// from `tips` in `scope` — the universal primitive, incl. encrypted
147    /// stores (client decrypts+merges locally). Gate Read.
148    GetStoreEntries {
149        store: String,
150        tips: Vec<ID>,
151        scope: ReadScope,
152    },
153    /// Subtree tips reachable from given main-tree entry IDs.
154    /// Used by Transaction internals to discover store entries.
155    GetStoreTipsUpToEntries { store: String, up_to: Vec<ID> },
156
157    /// Lowest common ancestor + path to tip entries in a store DAG.
158    /// Fused to one RPC: the only caller always calls find_merge_base
159    /// then get_path_from_to in sequence.
160    ComputeMergeState { store: String, entry_ids: Vec<ID> },
161
162    /// Fetch a single entry by id (gated post-fetch by its owning tree). Gate
163    /// Read.
164    GetEntry { id: ID },
165
166    /// Look up a cached materialized CRDT state. Server returns the previously
167    /// `CacheCrdtState`-submitted blob for `(session user, root_id, key, store)`,
168    /// or `None` on miss. Gate Read.
169    ///
170    /// Used by [`RemoteBackend::get_cached_crdt_state`](crate::instance::backend::RemoteBackend)
171    /// as the second tier of a two-level cache: the client first checks its own
172    /// per-connection LRU, then falls back to this RPC. The daemon's cache is
173    /// the cross-session source of truth.
174    GetCachedCrdtState { store: String, key: ID },
175
176    /// Stash a client-computed materialized CRDT state for `(session user,
177    /// root_id, key, store)`. Gate Read.
178    ///
179    /// **Per-user trust model**: the daemon stores whatever bytes the
180    /// authenticated user sends, scoped to their `user_uuid`. The blob is
181    /// **opaque** to the daemon — ciphertext for encrypted stores, plaintext
182    /// for plain ones — and the daemon performs no verification of the
183    /// merge result. The trust boundary is the same one the client would have
184    /// with a local-only cache: only the submitting user can poison their
185    /// future reads on this slot.
186    ///
187    /// **Tip-based natural expiry**: keys are derived from tip sets (see
188    /// `create_merge_cache_id`), so an entry whose tip set has advanced is
189    /// simply unreachable — future reads miss against a fresh key. Stale
190    /// entries fall out of the LRU under memory pressure rather than via
191    /// explicit invalidation.
192    CacheCrdtState {
193        store: String,
194        key: ID,
195        blob: Vec<u8>,
196    },
197
198    /// Rewrite the daemon's instance metadata (system-DB pointers). Gated by
199    /// `Admin` on `_databases` (a daemon-global system tree, resolved
200    /// server-side — *not* the request's `root_id`), so the per-tree gate is
201    /// special-cased for this variant in the dispatcher. Boxed to keep the
202    /// enum's stack footprint small — `InstanceMetadata` dominates its size.
203    SetInstanceMetadata { metadata: Box<InstanceMetadata> },
204
205    /// Subscribe this connection to write notifications for the request's
206    /// `root_id`, with an explicit initial cursor (`tips`).
207    ///
208    /// After the server returns `Ok`, every write the daemon observes on
209    /// that tree (local commits via `SubmitSignedEntry`, sync ingest via
210    /// `put_remote_entries`, etc.) is pushed back to this connection as a
211    /// [`Notification::DatabaseWrite`] frame. The frame's `previous_tips`
212    /// is computed from the daemon-side subscription cursor — initially
213    /// the `tips` supplied here, and advanced to each event's `post_tips`
214    /// as the daemon fires.
215    ///
216    /// **Cursor semantics**: pass the tips you just read your initial
217    /// state at. The first notification's `previous_tips` will exactly
218    /// equal `tips`, so the client can diff `tips → notification.post_tips`
219    /// to discover anything that happened between the initial read and
220    /// the daemon recognising the subscription. An empty `tips` means
221    /// "I have no initial state; start from the daemon's current view"
222    /// (the first notification's `previous_tips` will be the daemon's
223    /// tips at subscribe-time, captured under the per-tree lock).
224    ///
225    /// Idempotent: re-subscribing a tree this connection already
226    /// subscribed to is a no-op (`tips` on the re-call is ignored;
227    /// the cursor stays at whatever it was). Gate Read on `root_id`.
228    /// Subscriptions are cleared automatically when the connection
229    /// drops.
230    SubscribeWrites { tips: Snapshot },
231
232    /// Stop pushing write notifications for the request's `root_id` to this
233    /// connection. Idempotent: unsubscribing a tree that wasn't subscribed
234    /// is a no-op. Gate Read on `root_id`.
235    UnsubscribeWrites,
236}
237
238impl DatabaseOp {
239    /// Minimum permission the caller needs against the target database.
240    ///
241    /// Only `SubmitSignedEntry` mutates; everything else is a read. Every
242    /// read variant is tree-scoped via the request's `root_id`, so the
243    /// per-tree gate always runs for reads — there is no tree-less
244    /// fall-through. `SubmitSignedEntry` is the exception: the server skips
245    /// the per-tree gate for submit and relies on its own verification pass,
246    /// so the `Write(0)` returned here is advisory only for that variant
247    /// (kept for completeness / non-submit callers that inspect it).
248    pub fn required_permission(&self) -> Permission {
249        match self {
250            DatabaseOp::SubmitSignedEntry { .. } => Permission::Write(0),
251            // Gated against `_databases`, not the request's `root_id`; the
252            // dispatcher special-cases this so the value here is advisory.
253            DatabaseOp::SetInstanceMetadata { .. } => Permission::Admin(0),
254            DatabaseOp::GetStoreTipsUpToEntries { .. } => Permission::Read,
255            DatabaseOp::ComputeMergeState { .. } => Permission::Read,
256            DatabaseOp::SubscribeWrites { .. } => Permission::Read,
257            DatabaseOp::UnsubscribeWrites => Permission::Read,
258            _ => Permission::Read,
259        }
260    }
261}
262
263/// Payload of an `AuthenticatedDb` service request.
264///
265/// Bundles the database scope (`root_id`) and identity claim (`identity`) with
266/// the [`DatabaseOp`] to run. Boxed inside `ServiceRequest::AuthenticatedDb` to
267/// keep the top-level enum's stack footprint flat — `SigKey` and
268/// `DatabaseOp::SubmitSignedEntry` are large.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct AuthenticatedDbRequest {
271    /// Root entry of the database this op targets (auth-settings lookup +
272    /// the implicit tree scope every `DatabaseOp` carries by construction).
273    pub root_id: ID,
274    /// Identity claim; verified against the connection's session keyset
275    /// before dispatch.
276    pub identity: SigKey,
277    /// Database operation to execute.
278    pub op: DatabaseOp,
279}
280
281/// Top-level request from client to server.
282///
283/// The shape is intentionally flat: pre-auth lifecycle and queries sit beside
284/// the `AuthenticatedDb` wrapper rather than under a nested enum. This makes the
285/// pre-auth surface visible at a glance and keeps the server's dispatch
286/// branches symmetric.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub enum ServiceRequest {
289    // === Pre-auth: trusted login handshake ===
290    /// Step 1 of the trusted login flow. Client names a user; server responds
291    /// with a `TrustedLoginChallenge` carrying random bytes the client must
292    /// sign. The "Trusted" qualifier is a load-bearing reminder that this flow
293    /// assumes the caller is already trusted by the socket's filesystem
294    /// permissions — over a network transport this would need PAKE instead.
295    TrustedLoginUser { username: String },
296    /// Step 2 of the trusted login flow. Client returns a signature over the
297    /// challenge from `TrustedLoginUser`, computed with the user's root key.
298    /// Server verifies against the stored pubkey and, on success, marks the
299    /// connection authenticated.
300    TrustedLoginProve { signature: Vec<u8> },
301
302    // === Pre-auth: queries safe before login ===
303    /// Fetch the server's instance metadata (including device id). Used by
304    /// `Instance::connect` during the handshake to establish server identity.
305    GetInstanceMetadata,
306
307    // === Post-auth: extend the connection's session keyset ===
308    /// Step 1 of registering an additional pubkey on an already-authenticated
309    /// connection. The client names a `pubkey`; the server issues a random
310    /// challenge bound to that pubkey. The pubkey is added to the keyset only
311    /// after the client returns a valid signature in `SessionKeyRegister`.
312    ///
313    /// Session-key registration extends the connection's identity from the
314    /// single `login_pubkey` (from `TrustedLogin*`) to a *set* of pubkeys the
315    /// client has proven possession of. Per-tree reads gate against this set,
316    /// so a user can drive operations on databases authored by any of their
317    /// per-DB keys without re-authenticating the whole connection.
318    SessionKeyChallenge { pubkey: PublicKey },
319    /// Step 2 of registering an additional pubkey. Carries a signature over
320    /// the challenge issued by the matching `SessionKeyChallenge`. Server
321    /// verifies the signature with the named `pubkey`; on success the pubkey
322    /// joins the connection's session keyset and the challenge is consumed.
323    SessionKeyRegister {
324        pubkey: PublicKey,
325        signature: Vec<u8>,
326    },
327
328    // === Authenticated wrapper for every storage operation ===
329    /// All storage ops travel inside this wrapper. The inner
330    /// `AuthenticatedDbRequest` carries `(root_id, identity, op)` and is boxed
331    /// to keep the enum's discriminated size compact.
332    AuthenticatedDb(Box<AuthenticatedDbRequest>),
333}
334
335/// Server-initiated push to the client, interleaved with normal responses
336/// at any point after a connection has authenticated.
337///
338/// Notifications are not solicited by a specific request; the client signs up
339/// for them with a [`DatabaseOp::SubscribeWrites`] and unsubscribes by
340/// dropping the connection or sending [`DatabaseOp::UnsubscribeWrites`].
341///
342/// Notifications are **triggered only by settled-state writes** — i.e.
343/// entries that have passed local verification on the daemon. An entry
344/// that arrives `Unverified` (via sync, or as a `SubmitSignedEntry` body)
345/// is ingested silently and only produces a notification once the daemon's
346/// verification pass promotes it to `Verified`.
347///
348/// **This does not mean the bracket contains only Verified entries.** The
349/// cursors are raw DAG frontiers (`Backend::snapshot`), not the Verified
350/// frontier, so an `Unverified` or `Failed` entry sitting as a raw tip
351/// falls inside every subsequent bracket and
352/// [`Database::ids_added`](crate::Database::ids_added) will enumerate it.
353/// A subscriber that expands a bracket and fetches the IDs can therefore
354/// observe entries that failed the daemon's auth settings. Consumers that
355/// care must filter on verification status themselves; locally that is
356/// `Backend::get_verification_status`, and over the wire there is
357/// currently no way to do it at all — treat bracket-derived IDs as
358/// untrusted until read back through a gated path.
359///
360/// A second consequence of the raw-frontier cursor: because it advances
361/// past a still-`Unverified` tip, a later `verify()` that promotes that
362/// entry fires with `previous_tips == post_tips` for the subscriber, so
363/// `ids_added` returns empty and the promotion is never signalled. An
364/// entry can be reported once while untrusted and never mentioned again.
365///
366/// The frame carries cursor brackets only — no entry payloads, no entry
367/// IDs. Subscribers that need to enumerate the new entries expand the
368/// brackets locally via
369/// [`Database::ids_added`](crate::Database::ids_added). This decision
370/// has two consequences:
371///
372/// 1. **Security**: per-tree Read is gated once at `SubscribeWrites`; the
373///    publisher fan-out does not re-check on each event. Shipping
374///    cursors only means a subscriber whose permission is revoked
375///    mid-session can at most learn that *some* write happened on the
376///    tree — never the contents of those writes, which would only reach
377///    the client through an explicit, currently-gated read.
378/// 2. **Efficiency**: sync ingest can batch many entries; a cursor pair
379///    is constant-size regardless of batch width.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub enum Notification {
382    /// A settled-state write landed on the daemon for `root_id`.
383    ///
384    /// - `previous_tips` is the daemon-side subscription cursor at the
385    ///   moment of this fire — i.e. the `previous_tips` of the event
386    ///   the daemon dispatched to this subscription's callback. Useful
387    ///   for thin-forwarder topologies and trace/debug.
388    /// - `post_tips` is the daemon's tips *after* this write. The
389    ///   client uses it to advance every local per-callback cursor for
390    ///   this tree — each local callback's next event will have
391    ///   `previous_tips = post_tips` (the cursor moves forward by
392    ///   exactly one event).
393    /// - `source` distinguishes local-vs-sync for consumers that want
394    ///   to branch.
395    DatabaseWrite {
396        root_id: ID,
397        previous_tips: Snapshot,
398        post_tips: Snapshot,
399        source: WriteSource,
400    },
401}
402
403/// Envelope for every frame the server writes to a client.
404///
405/// Strict request/response responses ride `Response`; server-initiated
406/// pushes (subscribed write events) ride `Notification`. The reader task on
407/// the client demuxes by variant: `Response` frames go to the next pending
408/// oneshot in FIFO order, `Notification` frames go to the local callback
409/// dispatcher.
410///
411/// `Response` boxes its payload because `ServiceResponse` is large (the
412/// `TrustedLoginChallenge` variant carries a full `UserInfo`), and an
413/// unboxed enum would force every `Notification` frame to carry that
414/// stack footprint too.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub enum ServerFrame {
417    /// A response to a previously-sent [`ServiceRequest`].
418    Response(Box<ServiceResponse>),
419    /// A server-initiated push (subscription-driven).
420    Notification(Notification),
421}
422
423/// Response from server to client.
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub enum ServiceResponse {
426    /// Single entry
427    Entry(Entry),
428    /// Multiple entries
429    Entries(Vec<Entry>),
430    /// Multiple IDs
431    Ids(Snapshot),
432    /// Success with no data
433    Ok,
434    /// Transaction-build context (response to `DatabaseOp::BeginTransaction`).
435    TransactionContext(TransactionContext),
436    /// Materialized CRDT store state (response to `DatabaseOp::GetStoreState`).
437    CrdtValue(WireCrdtValue),
438    /// Merge state: lowest common ancestor + path to tips (response to
439    /// `DatabaseOp::ComputeMergeState`).
440    MergeState(MergeState),
441    /// Optional instance metadata
442    InstanceMetadata(Option<InstanceMetadata>),
443    /// Optional cached CRDT state blob (response to
444    /// `DatabaseOp::GetCachedCrdtState`). `None` on cache miss; the daemon
445    /// does not synthesize a value, so the client falls back to recomputing
446    /// from store entries.
447    CachedCrdtState(Option<Vec<u8>>),
448    /// Error response
449    Error(ServiceError),
450    /// Challenge bytes returned in response to `TrustedLoginUser`, plus the
451    /// user's full record so the client can derive the password→key, decrypt
452    /// the root signing key locally, sign the challenge in a single
453    /// round-trip, and then build the `User` session from data the daemon
454    /// already returned — no second wire read of `_users` is required.
455    ///
456    /// `user_info.credentials` carries the (encrypted) root private key, its
457    /// `KeyStorage` envelope (algorithm/ciphertext/nonce for password-protected
458    /// users, raw `PrivateKey` for passwordless users), and the Argon2id salt
459    /// when password-protected. The non-credential fields (user_database_id,
460    /// status, timestamps) are what `User::new` consumes after the proof
461    /// step succeeds. See § Trusted login threat model in the Service
462    /// Architecture doc for why this is safe to ship to anyone who can
463    /// reach the socket.
464    TrustedLoginChallenge {
465        challenge: Vec<u8>,
466        user_uuid: String,
467        user_info: UserInfo,
468    },
469    /// Trusted login succeeded; the connection is now authenticated.
470    TrustedLoginOk,
471    /// Challenge bytes returned in response to `SessionKeyChallenge`. The
472    /// client signs these with the named pubkey's private key and returns the
473    /// signature in `SessionKeyRegister`.
474    SessionKeyChallenge { challenge: Vec<u8> },
475}
476
477/// Write a length-prefixed JSON frame to an async writer.
478pub async fn write_frame<W: AsyncWrite + Unpin, T: Serialize>(
479    writer: &mut W,
480    value: &T,
481) -> crate::Result<()> {
482    let payload = serde_json::to_vec(value)?;
483    let len = payload.len() as u32;
484    if len > MAX_FRAME_SIZE {
485        return Err(crate::Error::Io(std::io::Error::new(
486            std::io::ErrorKind::InvalidData,
487            format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
488        )));
489    }
490    writer.write_all(&len.to_be_bytes()).await?;
491    writer.write_all(&payload).await?;
492    writer.flush().await?;
493    Ok(())
494}
495
496/// Read a length-prefixed JSON frame from an async reader.
497///
498/// Returns `None` on clean EOF (connection closed).
499pub async fn read_frame<R: AsyncRead + Unpin, T: for<'de> Deserialize<'de>>(
500    reader: &mut R,
501) -> crate::Result<Option<T>> {
502    let mut len_buf = [0u8; 4];
503    match reader.read_exact(&mut len_buf).await {
504        Ok(_) => {}
505        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
506        Err(e) => return Err(e.into()),
507    }
508    let len = u32::from_be_bytes(len_buf);
509    if len > MAX_FRAME_SIZE {
510        return Err(crate::Error::Io(std::io::Error::new(
511            std::io::ErrorKind::InvalidData,
512            format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
513        )));
514    }
515    let mut payload = vec![0u8; len as usize];
516    reader.read_exact(&mut payload).await?;
517    let value = serde_json::from_slice(&payload)?;
518    Ok(Some(value))
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    // Helper to make a simple entry for testing
526    fn test_id() -> ID {
527        ID::from_bytes("test-entry-id")
528    }
529
530    fn wrap(op: DatabaseOp) -> ServiceRequest {
531        ServiceRequest::AuthenticatedDb(Box::new(AuthenticatedDbRequest {
532            root_id: ID::default(),
533            identity: SigKey::default(),
534            op,
535        }))
536    }
537
538    /// Extract the inner `DatabaseOp` from a deserialised request, panicking if
539    /// the variant isn't `AuthenticatedDb`.
540    fn unwrap_op(req: ServiceRequest) -> DatabaseOp {
541        match req {
542            ServiceRequest::AuthenticatedDb(inner) => inner.op,
543            other => panic!("expected AuthenticatedDb, got {other:?}"),
544        }
545    }
546
547    #[test]
548    fn test_handshake_serde() {
549        let h = Handshake {
550            protocol_version: PROTOCOL_VERSION,
551        };
552        let json = serde_json::to_string(&h).unwrap();
553        let h2: Handshake = serde_json::from_str(&json).unwrap();
554        assert_eq!(h2.protocol_version, PROTOCOL_VERSION);
555    }
556
557    #[test]
558    fn test_handshake_ack_serde() {
559        let h = HandshakeAck {
560            protocol_version: PROTOCOL_VERSION,
561        };
562        let json = serde_json::to_string(&h).unwrap();
563        let h2: HandshakeAck = serde_json::from_str(&json).unwrap();
564        assert_eq!(h2.protocol_version, PROTOCOL_VERSION);
565    }
566
567    #[test]
568    fn test_request_get_entry_serde() {
569        let req = wrap(DatabaseOp::GetEntry { id: test_id() });
570        let json = serde_json::to_string(&req).unwrap();
571        let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
572        match unwrap_op(req2) {
573            DatabaseOp::GetEntry { id } => assert_eq!(id, test_id()),
574            _ => panic!("wrong variant"),
575        }
576    }
577
578    #[test]
579    fn test_request_get_instance_metadata_serde() {
580        let req = ServiceRequest::GetInstanceMetadata;
581        let json = serde_json::to_string(&req).unwrap();
582        let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
583        assert!(matches!(req2, ServiceRequest::GetInstanceMetadata));
584    }
585
586    #[test]
587    fn test_request_trusted_login_user_serde() {
588        let req = ServiceRequest::TrustedLoginUser {
589            username: "alice".to_string(),
590        };
591        let json = serde_json::to_string(&req).unwrap();
592        let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
593        match req2 {
594            ServiceRequest::TrustedLoginUser { username } => assert_eq!(username, "alice"),
595            _ => panic!("wrong variant"),
596        }
597    }
598
599    #[test]
600    fn test_request_trusted_login_prove_serde() {
601        let req = ServiceRequest::TrustedLoginProve {
602            signature: b"sig-bytes".to_vec(),
603        };
604        let json = serde_json::to_string(&req).unwrap();
605        let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
606        match req2 {
607            ServiceRequest::TrustedLoginProve { signature } => assert_eq!(signature, b"sig-bytes"),
608            _ => panic!("wrong variant"),
609        }
610    }
611
612    #[test]
613    fn test_response_ok_serde() {
614        let resp = ServiceResponse::Ok;
615        let json = serde_json::to_string(&resp).unwrap();
616        let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
617        assert!(matches!(resp2, ServiceResponse::Ok));
618    }
619
620    #[test]
621    fn test_response_ids_serde() {
622        let resp = ServiceResponse::Ids(Snapshot::new(vec![test_id(), ID::from_bytes("other")]));
623        let json = serde_json::to_string(&resp).unwrap();
624        let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
625        match resp2 {
626            ServiceResponse::Ids(ids) => {
627                assert_eq!(ids.len(), 2);
628                assert!(ids.contains(&test_id()));
629                assert!(ids.contains(&ID::from_bytes("other")));
630            }
631            _ => panic!("wrong variant"),
632        }
633    }
634
635    #[test]
636    fn test_response_error_serde() {
637        let se = ServiceError {
638            module: "backend".to_string(),
639            kind: "EntryNotFound".to_string(),
640            message: "Entry not found: abc".to_string(),
641        };
642        let resp = ServiceResponse::Error(se);
643        let json = serde_json::to_string(&resp).unwrap();
644        let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
645        match resp2 {
646            ServiceResponse::Error(e) => {
647                assert_eq!(e.module, "backend");
648                assert_eq!(e.kind, "EntryNotFound");
649            }
650            _ => panic!("wrong variant"),
651        }
652    }
653
654    #[test]
655    fn test_response_instance_metadata_none_serde() {
656        let resp = ServiceResponse::InstanceMetadata(None);
657        let json = serde_json::to_string(&resp).unwrap();
658        let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
659        assert!(matches!(resp2, ServiceResponse::InstanceMetadata(None)));
660    }
661
662    #[test]
663    fn test_response_trusted_login_challenge_serde() {
664        use crate::auth::crypto::generate_keypair;
665        use crate::user::{KeyStorage, UserCredentials, UserInfo, UserStatus};
666
667        let (_signing, pubkey) = generate_keypair();
668        let user_info = UserInfo {
669            username: "alice".to_string(),
670            user_database_id: ID::from_bytes("alice-db"),
671            credentials: UserCredentials {
672                root_key_id: pubkey.clone(),
673                root_key: KeyStorage::Encrypted {
674                    algorithm: "aes-256-gcm".to_string(),
675                    ciphertext: b"ct".to_vec(),
676                    nonce: b"123456789012".to_vec(),
677                },
678                password_salt: Some("salt-string".to_string()),
679            },
680            created_at: 1_700_000_000,
681            status: UserStatus::Active,
682        };
683
684        let resp = ServiceResponse::TrustedLoginChallenge {
685            challenge: b"random-bytes".to_vec(),
686            user_uuid: "uuid-alice".to_string(),
687            user_info: user_info.clone(),
688        };
689        let json = serde_json::to_string(&resp).unwrap();
690        let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
691        match resp2 {
692            ServiceResponse::TrustedLoginChallenge {
693                challenge,
694                user_uuid,
695                user_info: ui2,
696            } => {
697                assert_eq!(challenge, b"random-bytes");
698                assert_eq!(user_uuid, "uuid-alice");
699                assert_eq!(ui2.username, user_info.username);
700                assert_eq!(ui2.user_database_id, user_info.user_database_id);
701                assert_eq!(ui2.credentials.root_key_id, pubkey);
702                assert_eq!(
703                    ui2.credentials.password_salt.as_deref(),
704                    Some("salt-string")
705                );
706            }
707            _ => panic!("wrong variant"),
708        }
709    }
710
711    #[test]
712    fn test_response_trusted_login_ok_serde() {
713        let resp = ServiceResponse::TrustedLoginOk;
714        let json = serde_json::to_string(&resp).unwrap();
715        let resp2: ServiceResponse = serde_json::from_str(&json).unwrap();
716        assert!(matches!(resp2, ServiceResponse::TrustedLoginOk));
717    }
718
719    #[test]
720    fn test_database_op_subscribe_writes_serde() {
721        let req = wrap(DatabaseOp::SubscribeWrites {
722            tips: Snapshot::new(vec![ID::from_bytes("t1"), ID::from_bytes("t2")]),
723        });
724        let json = serde_json::to_string(&req).unwrap();
725        let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
726        match unwrap_op(req2) {
727            DatabaseOp::SubscribeWrites { tips } => assert_eq!(tips.len(), 2),
728            other => panic!("expected SubscribeWrites, got {other:?}"),
729        }
730    }
731
732    #[test]
733    fn test_database_op_subscribe_writes_empty_tips_serde() {
734        let req = wrap(DatabaseOp::SubscribeWrites {
735            tips: Snapshot::EMPTY,
736        });
737        let json = serde_json::to_string(&req).unwrap();
738        let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
739        match unwrap_op(req2) {
740            DatabaseOp::SubscribeWrites { tips } => assert!(tips.is_empty()),
741            other => panic!("expected SubscribeWrites, got {other:?}"),
742        }
743    }
744
745    #[test]
746    fn test_database_op_unsubscribe_writes_serde() {
747        let req = wrap(DatabaseOp::UnsubscribeWrites);
748        let json = serde_json::to_string(&req).unwrap();
749        let req2: ServiceRequest = serde_json::from_str(&json).unwrap();
750        assert!(matches!(unwrap_op(req2), DatabaseOp::UnsubscribeWrites));
751    }
752
753    #[test]
754    fn test_subscribe_ops_gate_read() {
755        assert_eq!(
756            DatabaseOp::SubscribeWrites {
757                tips: Snapshot::EMPTY
758            }
759            .required_permission(),
760            Permission::Read
761        );
762        assert_eq!(
763            DatabaseOp::UnsubscribeWrites.required_permission(),
764            Permission::Read
765        );
766    }
767
768    #[test]
769    fn test_server_frame_response_serde() {
770        let frame = ServerFrame::Response(Box::new(ServiceResponse::Ok));
771        let json = serde_json::to_string(&frame).unwrap();
772        let frame2: ServerFrame = serde_json::from_str(&json).unwrap();
773        match frame2 {
774            ServerFrame::Response(resp) => match *resp {
775                ServiceResponse::Ok => {}
776                other => panic!("expected ServiceResponse::Ok, got {other:?}"),
777            },
778            other => panic!("expected ServerFrame::Response(Ok), got {other:?}"),
779        }
780    }
781
782    #[test]
783    fn test_server_frame_notification_serde() {
784        let notif = Notification::DatabaseWrite {
785            root_id: test_id(),
786            previous_tips: Snapshot::new(vec![ID::from_bytes("tip-1"), ID::from_bytes("tip-2")]),
787            post_tips: Snapshot::new(vec![ID::from_bytes("post-1")]),
788            source: WriteSource::Remote,
789        };
790        let frame = ServerFrame::Notification(notif);
791        let json = serde_json::to_string(&frame).unwrap();
792        let frame2: ServerFrame = serde_json::from_str(&json).unwrap();
793        match frame2 {
794            ServerFrame::Notification(Notification::DatabaseWrite {
795                root_id,
796                previous_tips,
797                post_tips,
798                source,
799            }) => {
800                assert_eq!(root_id, test_id());
801                assert_eq!(previous_tips.len(), 2);
802                assert_eq!(post_tips, Snapshot::new(vec![ID::from_bytes("post-1")]));
803                assert_eq!(source, WriteSource::Remote);
804            }
805            other => panic!("expected ServerFrame::Notification(DatabaseWrite), got {other:?}"),
806        }
807    }
808
809    #[tokio::test]
810    async fn test_frame_eof_returns_none() {
811        // Use a real Unix socket pair for proper EOF semantics
812        let dir = tempfile::tempdir().unwrap();
813        let sock_path = dir.path().join("eof-test.sock");
814        let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
815
816        let client = tokio::net::UnixStream::connect(&sock_path).await.unwrap();
817        let (server_stream, _) = listener.accept().await.unwrap();
818
819        // Drop the server stream to close the connection
820        drop(server_stream);
821
822        let (mut reader, _writer) = tokio::io::split(client);
823        let result: crate::Result<Option<ServiceRequest>> = read_frame(&mut reader).await;
824        assert!(result.unwrap().is_none());
825    }
826
827    #[tokio::test]
828    async fn test_frame_max_size_rejection_on_write() {
829        let (client, _server) = tokio::io::duplex(1024);
830        let (_read, mut write) = tokio::io::split(client);
831
832        // Create a payload that's too large
833        let huge_string = "x".repeat(MAX_FRAME_SIZE as usize + 1);
834        let result = write_frame(&mut write, &huge_string).await;
835        assert!(result.is_err());
836    }
837
838    #[tokio::test]
839    async fn test_frame_max_size_rejection_on_read() {
840        let (client, server) = tokio::io::duplex(1024 * 1024);
841        let (mut client_read, _client_write) = tokio::io::split(client);
842        let (_server_read, mut server_write) = tokio::io::split(server);
843
844        // Write a fake frame header with size > MAX_FRAME_SIZE from the server end
845        let fake_len = MAX_FRAME_SIZE + 1;
846        tokio::spawn(async move {
847            server_write
848                .write_all(&fake_len.to_be_bytes())
849                .await
850                .unwrap();
851        });
852
853        let result: crate::Result<Option<ServiceRequest>> = read_frame(&mut client_read).await;
854        assert!(result.is_err());
855    }
856}