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}