Skip to main content

derec_library/protocol/handlers/
restore.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 DeRec Alliance. All rights reserved.
3
4//! Post-recovery rebuild handler.
5//!
6//! Takes a [`Secret`] handed up by a
7//! [`DeRecEvent::SecretRecovered`](super::super::DeRecEvent::SecretRecovered)
8//! event and reseats the protocol's `secret_id` namespace from it:
9//! writes canonical helper / replica channel records, commits the
10//! user-secret snapshot, then unpairs every other channel under the
11//! `secret_id` (the recovery-mode channels minted to drive
12//! `start(RecoverSecret)` — scrap after restore commits).
13//!
14//! Two preconditions are reported as [`RestoreError`] (wrapped in
15//! [`crate::Error::Restore`]) **before any store mutation** — a
16//! precondition error is exactly equivalent to never having called
17//! restore:
18//!
19//! - [`RestoreError::AlreadyRestored`] — a user-secret snapshot
20//!   already exists for this `secret_id`.
21//! - [`RestoreError::Conflict`] — a channel already lives at one of
22//!   the canonical helper / replica ids carried by the recovered
23//!   `Secret`.
24//!
25//! Store I/O failures mid-restore propagate as their underlying
26//! [`crate::Error`] variant (`ShareStore`, `ChannelStore`,
27//! `SecretStore`). The snapshot write is the commit point — nothing
28//! is removed before it succeeds, so any mid-flight failure leaves
29//! state the next `restore` call can detect as one of the
30//! preconditions above.
31
32use super::super::{
33    DeRecChannelStore, DeRecEvent, DeRecSecretStore, DeRecShareStore, DeRecStateStore,
34    DeRecTransport, DeRecUserSecretStore, SecretValue, UnpairAck,
35    types::{Channel, ChannelStatus, HelperInfo, Replicas, Secret, Share, UserSecrets},
36};
37use crate::{
38    Result,
39    types::{ChannelId, SharedKey},
40    utils::SenderKindExt as _,
41};
42use std::collections::HashSet;
43
44#[cfg(not(target_arch = "wasm32"))]
45use crate::utils::now_secs;
46#[cfg(target_arch = "wasm32")]
47use crate::wasm::now_secs;
48
49/// Restore-specific failure modes surfaced via [`crate::Error::Restore`].
50/// Every variant is reported **before any store mutation** — a
51/// precondition error is exactly equivalent to never having called
52/// restore. Store I/O failures mid-restore propagate as their
53/// underlying [`crate::Error`] variant instead.
54#[derive(Debug, thiserror::Error)]
55#[non_exhaustive]
56pub enum RestoreError {
57    /// A user-secret snapshot already exists for the protocol's
58    /// `secret_id`. The application must clear it before retrying.
59    #[error("user-secret snapshot already exists for secret_id")]
60    AlreadyRestored,
61
62    /// One or more channels already live at canonical helper or
63    /// replica ids carried by the recovered `Secret`. The contained
64    /// list enumerates the collisions; the application clears them
65    /// through its own store wrappers and retries.
66    #[error("restore blocked by pre-existing channels at canonical ids")]
67    Conflict(Vec<ChannelId>),
68
69    /// The recovered [`Secret`] is internally inconsistent. Only
70    /// reachable when a `Secret` was hand-crafted — library-produced
71    /// ones always satisfy the invariants (rebuilt inside the sharing
72    /// handler during a `start(ProtectSecret)` round).
73    #[error("recovered Secret is internally inconsistent: {0}")]
74    Invariant(&'static str),
75}
76
77/// Run the restore flow. On success: canonical helper channels are
78/// persisted with `SharedKey` + owner-side tracking shares at
79/// `recovered_version`; canonical replica channels are persisted with
80/// the group key from `secret.replicas.shared_key`; the user-secret
81/// snapshot is committed at `recovered_version`; `local_replica_id`
82/// adopts `secret.owner_replica_id` if previously unset; every
83/// recovery-mode channel under `secret_id` is unpaired
84/// (`UnpairAck::NotRequired`). The returned events come from the
85/// recovery-channel wipe and should be drained into the protocol's
86/// `pending_start_events`.
87#[cfg_attr(feature = "logging", tracing::instrument(skip_all))]
88#[allow(clippy::too_many_arguments)]
89pub(in crate::protocol) async fn restore<
90    Ch: DeRecChannelStore,
91    Sh: DeRecShareStore,
92    Ss: DeRecSecretStore,
93    Us: DeRecUserSecretStore,
94    T: DeRecTransport,
95    St: DeRecStateStore,
96>(
97    channel_store: &mut Ch,
98    share_store: &mut Sh,
99    secret_store: &mut Ss,
100    user_secret_store: &mut Us,
101    transport: &T,
102    state_store: &mut St,
103    local_replica_id: &mut Option<u64>,
104    secret_id: u64,
105    secret: &Secret,
106    recovered_version: u32,
107) -> Result<Vec<DeRecEvent>> {
108    let (canonical_ids, existing_channels) =
109        check_preconditions(user_secret_store, channel_store, secret_id, secret).await?;
110
111    write_helper_channels(
112        channel_store,
113        share_store,
114        secret_store,
115        secret_id,
116        &secret.helpers,
117        recovered_version,
118    )
119    .await?;
120
121    if let Some(group) = secret.replicas.as_ref().filter(|g| !g.replicas.is_empty()) {
122        write_replica_channels(channel_store, secret_store, secret_id, group).await?;
123    }
124
125    commit_snapshot(user_secret_store, secret_id, secret, recovered_version).await?;
126
127    adopt_owner_replica_id(local_replica_id, secret.owner_replica_id);
128
129    let events = unpair_recovery_channels(
130        channel_store,
131        share_store,
132        secret_store,
133        transport,
134        state_store,
135        secret_id,
136        &existing_channels,
137        &canonical_ids,
138    )
139    .await?;
140
141    #[cfg(feature = "logging")]
142    tracing::info!(
143        secret_id,
144        helpers_restored = secret.helpers.len(),
145        replicas_restored = secret.replicas.as_ref().map_or(0, |g| g.replicas.len()),
146        user_secrets_restored = secret.secrets.len(),
147        "DeRecProtocol restored from recovered Secret"
148    );
149
150    Ok(events)
151}
152
153/// Validate that the protocol is in a state where restore can run AND
154/// collect the data the rest of the flow needs (canonical id set +
155/// the current channel list, reused for the recovery-channel wipe).
156///
157/// Surfaces [`RestoreError::AlreadyRestored`] when a snapshot is
158/// already committed, [`RestoreError::Invariant`] when
159/// `secret.replicas.shared_key` is mis-sized, and
160/// [`RestoreError::Conflict`] when an existing channel sits at a
161/// canonical helper / replica id. All three are reported before any
162/// store mutation.
163async fn check_preconditions<Ch: DeRecChannelStore, Us: DeRecUserSecretStore>(
164    user_secret_store: &Us,
165    channel_store: &Ch,
166    secret_id: u64,
167    secret: &Secret,
168) -> Result<(HashSet<u64>, Vec<Channel>)> {
169    if user_secret_store.load_latest(secret_id).await?.is_some() {
170        return Err(RestoreError::AlreadyRestored.into());
171    }
172
173    if let Some(group) = &secret.replicas {
174        if !group.replicas.is_empty() && group.shared_key.len() != 32 {
175            return Err(RestoreError::Invariant(
176                "recovered Secret carries replicas but replicas.shared_key is missing or wrong size",
177            )
178            .into());
179        }
180    }
181
182    // Channels not at canonical ids are recovery channels — wiped
183    // after the commit, not flagged as collisions.
184    let canonical_ids: HashSet<u64> = secret
185        .helpers
186        .iter()
187        .map(|h| h.channel_id)
188        .chain(
189            secret
190                .replicas
191                .as_ref()
192                .into_iter()
193                .flat_map(|g| g.replicas.iter().map(|r| r.channel_id)),
194        )
195        .collect();
196    let existing_channels = channel_store.channels(secret_id).await?;
197    let collisions: Vec<ChannelId> = existing_channels
198        .iter()
199        .filter(|c| canonical_ids.contains(&c.id.0))
200        .map(|c| c.id)
201        .collect();
202    if !collisions.is_empty() {
203        return Err(RestoreError::Conflict(collisions).into());
204    }
205
206    Ok((canonical_ids, existing_channels))
207}
208
209/// Persist each helper's canonical channel record, its `SharedKey`,
210/// and an empty owner-side tracking [`Share`] at `recovered_version`.
211async fn write_helper_channels<Ch: DeRecChannelStore, Sh: DeRecShareStore, Ss: DeRecSecretStore>(
212    channel_store: &mut Ch,
213    share_store: &mut Sh,
214    secret_store: &mut Ss,
215    secret_id: u64,
216    helpers: &[HelperInfo],
217    recovered_version: u32,
218) -> Result<()> {
219    for h in helpers {
220        let cid = ChannelId(h.channel_id);
221        let shared_key: SharedKey = h
222            .shared_key
223            .as_slice()
224            .try_into()
225            .map_err(|_| RestoreError::Invariant("helper.shared_key must be 32 bytes"))?;
226        channel_store
227            .save(
228                secret_id,
229                Channel {
230                    id: cid,
231                    transport: derec_proto::TransportProtocol {
232                        uri: h.transport_uri.clone(),
233                        protocol: derec_proto::Protocol::Https as i32,
234                    },
235                    communication_info: h.communication_info.clone(),
236                    status: ChannelStatus::Paired,
237                    created_at: now_secs(),
238                    role: derec_proto::SenderKind::Owner,
239                    replica_id: None,
240                },
241            )
242            .await?;
243        secret_store
244            .save(secret_id, cid, SecretValue::SharedKey(shared_key))
245            .await?;
246        share_store
247            .save(
248                secret_id,
249                cid,
250                Share {
251                    secret_id,
252                    version: recovered_version,
253                    replica_id: None,
254                    bytes: Vec::new(),
255                },
256            )
257            .await?;
258    }
259    Ok(())
260}
261
262/// Persist each replica destination's canonical channel record
263/// (with the group key as its `SharedKey`). The local role on each
264/// restored channel is the inverse of the peer's `sender_kind`
265/// carried in the recovered `Secret`.
266///
267/// Caller guarantees `group.replicas` is non-empty.
268async fn write_replica_channels<Ch: DeRecChannelStore, Ss: DeRecSecretStore>(
269    channel_store: &mut Ch,
270    secret_store: &mut Ss,
271    secret_id: u64,
272    group: &Replicas,
273) -> Result<()> {
274    let group_key: SharedKey = group.shared_key.as_slice().try_into().map_err(|_| {
275        RestoreError::Invariant("replicas.shared_key must be 32 bytes when replicas is non-empty")
276    })?;
277    for r in &group.replicas {
278        let peer_kind = derec_proto::SenderKind::try_from(r.sender_kind)
279            .map_err(|_| RestoreError::Invariant("replica.sender_kind invalid"))?;
280        let local_role = peer_kind.derive_peer();
281        let cid = ChannelId(r.channel_id);
282        channel_store
283            .save(
284                secret_id,
285                Channel {
286                    id: cid,
287                    transport: derec_proto::TransportProtocol {
288                        uri: r.transport_uri.clone(),
289                        protocol: derec_proto::Protocol::Https as i32,
290                    },
291                    communication_info: r.communication_info.clone(),
292                    status: ChannelStatus::Paired,
293                    created_at: now_secs(),
294                    role: local_role,
295                    replica_id: Some(r.replica_id),
296                },
297            )
298            .await?;
299        secret_store
300            .save(secret_id, cid, SecretValue::SharedKey(group_key))
301            .await?;
302    }
303    Ok(())
304}
305
306/// Commit the user-secret snapshot at `recovered_version`. This write
307/// is the commit point — nothing is removed before it succeeds, so any
308/// earlier failure is fully retryable.
309async fn commit_snapshot<Us: DeRecUserSecretStore>(
310    user_secret_store: &mut Us,
311    secret_id: u64,
312    secret: &Secret,
313    recovered_version: u32,
314) -> Result<()> {
315    user_secret_store
316        .save_latest(
317            secret_id,
318            UserSecrets {
319                version: recovered_version,
320                secrets: secret.secrets.clone(),
321                description: None,
322                replicas: secret.replicas.clone(),
323            },
324        )
325        .await?;
326    Ok(())
327}
328
329/// Adopt `owner_replica_id` from the recovered `Secret` when the
330/// builder left the local replica id unset. Zero is the "no replica
331/// id" sentinel — don't adopt it.
332fn adopt_owner_replica_id(local_replica_id: &mut Option<u64>, owner_replica_id: u64) {
333    if local_replica_id.is_none() && owner_replica_id != 0 {
334        *local_replica_id = Some(owner_replica_id);
335    }
336}
337
338/// Send unpair requests to every channel that isn't at a canonical id
339/// (i.e. the recovery-mode channels minted to drive
340/// `start(RecoverSecret)`) and drop local state. Forces
341/// `UnpairAck::NotRequired` so the wipe is synchronous regardless of
342/// the protocol's configured ack mode.
343#[allow(clippy::too_many_arguments)]
344async fn unpair_recovery_channels<
345    Ch: DeRecChannelStore,
346    Sh: DeRecShareStore,
347    Ss: DeRecSecretStore,
348    T: DeRecTransport,
349    St: DeRecStateStore,
350>(
351    channel_store: &mut Ch,
352    share_store: &mut Sh,
353    secret_store: &mut Ss,
354    transport: &T,
355    state_store: &mut St,
356    secret_id: u64,
357    existing_channels: &[Channel],
358    canonical_ids: &HashSet<u64>,
359) -> Result<Vec<DeRecEvent>> {
360    let recovery_ids: Vec<ChannelId> = existing_channels
361        .iter()
362        .filter(|c| !canonical_ids.contains(&c.id.0))
363        .map(|c| c.id)
364        .collect();
365    if recovery_ids.is_empty() {
366        return Ok(Vec::new());
367    }
368    let now = now_secs();
369    let mut events = Vec::new();
370    for channel_id in recovery_ids {
371        let mut per_channel = super::unpairing::start(
372            channel_store,
373            share_store,
374            secret_store,
375            transport,
376            state_store,
377            secret_id,
378            channel_id,
379            None,
380            UnpairAck::NotRequired,
381            now,
382            None,
383        )
384        .await?;
385        events.append(&mut per_channel);
386    }
387    Ok(events)
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::protocol::DeRecProtocolBuilder;
394    use crate::protocol::traits::{
395        ChannelStoreFuture, DeRecChannelStore, DeRecSecretStore, DeRecShareStore, DeRecTransport,
396        DeRecUserSecretStore, SecretStoreFuture, ShareStoreFuture, TransportFuture,
397    };
398    use crate::protocol::types::{
399        Channel, ChannelStatus, HelperInfo, MissingPolicy, ReplicaInfo, Replicas, Secret,
400        SecretKind, SecretValue, Share, UserSecret, UserSecrets,
401    };
402    use derec_proto::{SenderKind, TransportProtocol};
403    use std::collections::HashMap;
404    use std::sync::{Arc, Mutex};
405
406    // ---- In-memory store impls, shared across both test paths ----------
407    //
408    // `Arc<Mutex<...>>` so we can pre-seed the inner data BEFORE building
409    // the protocol (the protocol owns the impl, so the only mutation
410    // path post-construction is through the trait).
411
412    #[derive(Default, Clone)]
413    struct InMemChannelStore {
414        data: Arc<Mutex<HashMap<(u64, u64), Channel>>>,
415    }
416    impl DeRecChannelStore for InMemChannelStore {
417        fn load(&self, sid: u64, cid: ChannelId) -> ChannelStoreFuture<'_, Option<Channel>> {
418            let v = self.data.lock().unwrap().get(&(sid, cid.0)).cloned();
419            Box::pin(std::future::ready(Ok(v)))
420        }
421        fn save(&mut self, sid: u64, c: Channel) -> ChannelStoreFuture<'_, ()> {
422            self.data.lock().unwrap().insert((sid, c.id.0), c);
423            Box::pin(std::future::ready(Ok(())))
424        }
425        fn remove(&mut self, sid: u64, cid: ChannelId) -> ChannelStoreFuture<'_, bool> {
426            let removed = self.data.lock().unwrap().remove(&(sid, cid.0)).is_some();
427            Box::pin(std::future::ready(Ok(removed)))
428        }
429        fn channels(&self, sid: u64) -> ChannelStoreFuture<'_, Vec<Channel>> {
430            let v: Vec<Channel> = self
431                .data
432                .lock()
433                .unwrap()
434                .iter()
435                .filter(|((s, _), _)| *s == sid)
436                .map(|(_, c)| c.clone())
437                .collect();
438            Box::pin(std::future::ready(Ok(v)))
439        }
440        fn link_channel(
441            &mut self,
442            _: u64,
443            _: ChannelId,
444            _: ChannelId,
445        ) -> ChannelStoreFuture<'_, ()> {
446            Box::pin(std::future::ready(Ok(())))
447        }
448        fn linked_channels(
449            &self,
450            _: u64,
451            cid: ChannelId,
452        ) -> ChannelStoreFuture<'_, Vec<ChannelId>> {
453            Box::pin(std::future::ready(Ok(vec![cid])))
454        }
455    }
456
457    #[derive(Default, Clone)]
458    struct InMemSecretStore {
459        #[allow(clippy::type_complexity)]
460        data: Arc<Mutex<HashMap<(u64, u64, u8), SecretValue>>>,
461    }
462    impl DeRecSecretStore for InMemSecretStore {
463        fn load(
464            &self,
465            sid: u64,
466            cid: ChannelId,
467            kind: SecretKind,
468        ) -> SecretStoreFuture<'_, Option<SecretValue>> {
469            let v = self
470                .data
471                .lock()
472                .unwrap()
473                .get(&(sid, cid.0, kind as u8))
474                .cloned();
475            Box::pin(std::future::ready(Ok(v)))
476        }
477        fn load_many(
478            &self,
479            sid: u64,
480            cids: &[ChannelId],
481            kind: SecretKind,
482            _: MissingPolicy,
483        ) -> SecretStoreFuture<'_, Vec<(ChannelId, SecretValue)>> {
484            let mut out = Vec::new();
485            for c in cids {
486                if let Some(v) = self.data.lock().unwrap().get(&(sid, c.0, kind as u8)) {
487                    out.push((*c, v.clone()));
488                }
489            }
490            Box::pin(std::future::ready(Ok(out)))
491        }
492        fn save(
493            &mut self,
494            sid: u64,
495            cid: ChannelId,
496            value: SecretValue,
497        ) -> SecretStoreFuture<'_, ()> {
498            let k = match &value {
499                SecretValue::SharedKey(_) => SecretKind::SharedKey as u8,
500                SecretValue::PairingSecret(_) => SecretKind::PairingSecret as u8,
501                SecretValue::PairingContact(_) => SecretKind::PairingContact as u8,
502            };
503            self.data.lock().unwrap().insert((sid, cid.0, k), value);
504            Box::pin(std::future::ready(Ok(())))
505        }
506        fn remove(
507            &mut self,
508            sid: u64,
509            cid: ChannelId,
510            kind: SecretKind,
511        ) -> SecretStoreFuture<'_, ()> {
512            self.data.lock().unwrap().remove(&(sid, cid.0, kind as u8));
513            Box::pin(std::future::ready(Ok(())))
514        }
515    }
516
517    #[derive(Default, Clone)]
518    struct InMemShareStore {
519        #[allow(clippy::type_complexity)]
520        data: Arc<Mutex<HashMap<(u64, u64, u32), Share>>>,
521    }
522    impl DeRecShareStore for InMemShareStore {
523        fn load(
524            &self,
525            sid: u64,
526            cid: ChannelId,
527            versions: &[u32],
528        ) -> ShareStoreFuture<'_, Vec<Share>> {
529            let lock = self.data.lock().unwrap();
530            let out: Vec<Share> = lock
531                .iter()
532                .filter(|((s, c, v), _)| {
533                    *s == sid && *c == cid.0 && (versions.is_empty() || versions.contains(v))
534                })
535                .map(|(_, s)| s.clone())
536                .collect();
537            Box::pin(std::future::ready(Ok(out)))
538        }
539        fn load_many(
540            &self,
541            _: u64,
542            _: &[ChannelId],
543            _: &[u32],
544        ) -> ShareStoreFuture<'_, Vec<Share>> {
545            Box::pin(std::future::ready(Ok(Vec::new())))
546        }
547        fn load_all(&self, _: u64, _: &[ChannelId]) -> ShareStoreFuture<'_, Vec<Share>> {
548            Box::pin(std::future::ready(Ok(Vec::new())))
549        }
550        fn latest_version(&self, _: u64) -> ShareStoreFuture<'_, Option<u32>> {
551            Box::pin(std::future::ready(Ok(None)))
552        }
553        fn save(&mut self, sid: u64, cid: ChannelId, share: Share) -> ShareStoreFuture<'_, ()> {
554            let v = share.version;
555            self.data.lock().unwrap().insert((sid, cid.0, v), share);
556            Box::pin(std::future::ready(Ok(())))
557        }
558        fn remove_channel(&mut self, _: u64, _: ChannelId) -> ShareStoreFuture<'_, ()> {
559            Box::pin(std::future::ready(Ok(())))
560        }
561    }
562
563    #[derive(Default, Clone)]
564    struct InMemUserSecretStore {
565        data: Arc<Mutex<HashMap<u64, UserSecrets>>>,
566    }
567    impl DeRecUserSecretStore for InMemUserSecretStore {
568        fn load_latest(&self, sid: u64) -> ShareStoreFuture<'_, Option<UserSecrets>> {
569            let v = self.data.lock().unwrap().get(&sid).cloned();
570            Box::pin(std::future::ready(Ok(v)))
571        }
572        fn save_latest(&mut self, sid: u64, value: UserSecrets) -> ShareStoreFuture<'_, ()> {
573            self.data.lock().unwrap().insert(sid, value);
574            Box::pin(std::future::ready(Ok(())))
575        }
576        fn remove(&mut self, sid: u64) -> ShareStoreFuture<'_, ()> {
577            self.data.lock().unwrap().remove(&sid);
578            Box::pin(std::future::ready(Ok(())))
579        }
580    }
581
582    #[derive(Default, Clone)]
583    struct NoopTransport;
584    impl DeRecTransport for NoopTransport {
585        fn send(&self, _: &TransportProtocol, _: Vec<u8>) -> TransportFuture<'_> {
586            Box::pin(std::future::ready(Ok(())))
587        }
588    }
589
590    type TestProto = crate::protocol::DeRecProtocol<
591        InMemChannelStore,
592        InMemShareStore,
593        InMemSecretStore,
594        InMemUserSecretStore,
595        RestoreTestStateStore,
596        NoopTransport,
597    >;
598
599    #[derive(Default)]
600    struct RestoreTestStateStore;
601    impl crate::protocol::DeRecStateStore for RestoreTestStateStore {
602        fn save(
603            &mut self,
604            _: u64,
605            _: crate::protocol::StateItem,
606        ) -> crate::protocol::StateStoreFuture<'_, ()> {
607            Box::pin(std::future::ready(Ok(())))
608        }
609        fn load(
610            &self,
611            _: u64,
612            _: crate::protocol::StateKey,
613        ) -> crate::protocol::StateStoreFuture<'_, Option<crate::protocol::StateItem>> {
614            Box::pin(std::future::ready(Ok(None)))
615        }
616        fn remove(
617            &mut self,
618            _: u64,
619            _: crate::protocol::StateKey,
620        ) -> crate::protocol::StateStoreFuture<'_, bool> {
621            Box::pin(std::future::ready(Ok(false)))
622        }
623        fn load_all(
624            &self,
625            _: u64,
626            _: crate::protocol::StateKind,
627        ) -> crate::protocol::StateStoreFuture<'_, Vec<crate::protocol::StateItem>> {
628            Box::pin(std::future::ready(Ok(Vec::new())))
629        }
630    }
631
632    /// Test bundle — keeps clone handles to every store so the test
633    /// can both pre-seed before construction AND inspect after the
634    /// restore call.
635    struct TestRig {
636        protocol: TestProto,
637        channel_store: InMemChannelStore,
638        secret_store: InMemSecretStore,
639        share_store: InMemShareStore,
640        user_secret_store: InMemUserSecretStore,
641    }
642
643    fn build_rig(secret_id: u64) -> TestRig {
644        let channel_store = InMemChannelStore::default();
645        let secret_store = InMemSecretStore::default();
646        let share_store = InMemShareStore::default();
647        let user_secret_store = InMemUserSecretStore::default();
648        let protocol = DeRecProtocolBuilder::new(secret_id)
649            .with_channel_store(channel_store.clone())
650            .with_share_store(share_store.clone())
651            .with_secret_store(secret_store.clone())
652            .with_user_secret_store(user_secret_store.clone())
653            .with_transport(NoopTransport)
654            .with_state_store(RestoreTestStateStore)
655            .with_own_transport("https://owner.example.com")
656            .with_threshold(2)
657            .build()
658            .expect("test rig builds");
659        TestRig {
660            protocol,
661            channel_store,
662            secret_store,
663            share_store,
664            user_secret_store,
665        }
666    }
667
668    fn fixture_secret() -> Secret {
669        Secret {
670            helpers: vec![
671                HelperInfo {
672                    channel_id: 11,
673                    transport_uri: "https://helper-a.example".to_owned(),
674                    shared_key: vec![0xAA; 32],
675                    communication_info: HashMap::from([("name".to_owned(), "HelperA".to_owned())]),
676                },
677                HelperInfo {
678                    channel_id: 12,
679                    transport_uri: "https://helper-b.example".to_owned(),
680                    shared_key: vec![0xBB; 32],
681                    communication_info: HashMap::new(),
682                },
683            ],
684            secrets: vec![
685                UserSecret {
686                    id: vec![0x01],
687                    name: "wallet".to_owned(),
688                    data: b"correct horse battery staple".to_vec(),
689                },
690                UserSecret {
691                    id: vec![0x02],
692                    name: "api token".to_owned(),
693                    data: b"hunter2".to_vec(),
694                },
695            ],
696            replicas: Some(Replicas {
697                replicas: vec![ReplicaInfo {
698                    channel_id: 21,
699                    transport_uri: "https://replica.example".to_owned(),
700                    communication_info: HashMap::new(),
701                    replica_id: 0xCAFE,
702                    sender_kind: SenderKind::ReplicaDestination as i32,
703                }],
704                shared_key: vec![0xCC; 32],
705            }),
706            owner_replica_id: 0xBEEF,
707        }
708    }
709
710    fn run_async<F: std::future::Future<Output = ()>>(f: F) {
711        tokio::runtime::Builder::new_current_thread()
712            .build()
713            .expect("test runtime")
714            .block_on(f)
715    }
716
717    // ---------------- Happy path ----------------
718
719    #[test]
720    fn restore_happy_path_persists_canonical_state() {
721        run_async(async {
722            let secret_id: u64 = 0xDE_2EC;
723            let mut rig = build_rig(secret_id);
724
725            rig.protocol
726                .restore(&fixture_secret(), 7)
727                .await
728                .expect("happy path must succeed");
729
730            // Helper channels: status=Paired, role=Owner, no replica_id.
731            for hid in [11_u64, 12] {
732                let ch = rig
733                    .channel_store
734                    .load(secret_id, ChannelId(hid))
735                    .await
736                    .unwrap()
737                    .expect("helper channel must be persisted");
738                assert_eq!(ch.status, ChannelStatus::Paired);
739                assert_eq!(ch.role, SenderKind::Owner);
740                assert!(ch.replica_id.is_none());
741                let sk = rig
742                    .secret_store
743                    .load(secret_id, ChannelId(hid), SecretKind::SharedKey)
744                    .await
745                    .unwrap()
746                    .expect("helper SharedKey must be persisted");
747                assert!(matches!(sk, SecretValue::SharedKey(_)));
748                let shares = rig
749                    .share_store
750                    .load(secret_id, ChannelId(hid), &[])
751                    .await
752                    .unwrap();
753                assert_eq!(shares.len(), 1);
754                assert_eq!(shares[0].version, 7);
755            }
756
757            // Replica channel: role inverted from peer's sender_kind,
758            // group key persisted.
759            let rep = rig
760                .channel_store
761                .load(secret_id, ChannelId(21))
762                .await
763                .unwrap()
764                .expect("replica channel must be persisted");
765            assert_eq!(rep.status, ChannelStatus::Paired);
766            assert_eq!(rep.role, SenderKind::ReplicaSource);
767            assert_eq!(rep.replica_id, Some(0xCAFE));
768            let rep_sk = rig
769                .secret_store
770                .load(secret_id, ChannelId(21), SecretKind::SharedKey)
771                .await
772                .unwrap()
773                .expect("replica group key must be persisted");
774            match rep_sk {
775                SecretValue::SharedKey(k) => assert_eq!(k.to_vec(), vec![0xCC; 32]),
776                _ => panic!("expected SharedKey"),
777            }
778
779            // User-secret snapshot at recovered version.
780            let snapshot = rig
781                .user_secret_store
782                .load_latest(secret_id)
783                .await
784                .unwrap()
785                .expect("snapshot must exist");
786            assert_eq!(snapshot.version, 7);
787            assert_eq!(snapshot.secrets.len(), 2);
788            assert_eq!(snapshot.secrets[0].name, "wallet");
789            assert_eq!(snapshot.secrets[0].data, b"correct horse battery staple");
790            assert_eq!(snapshot.secrets[1].name, "api token");
791
792            // replica_id adopted (builder default was None).
793            assert_eq!(rig.protocol.replica_id(), Some(0xBEEF));
794        });
795    }
796
797    // ---------------- Recovery-channel wipe ----------------
798
799    #[test]
800    fn restore_unpairs_pre_existing_recovery_channels() {
801        run_async(async {
802            let secret_id: u64 = 0xDE_2EC;
803            let mut rig = build_rig(secret_id);
804
805            // Two recovery channels at ids that don't collide with any
806            // canonical helper or replica id from `fixture_secret`.
807            // Each needs a SharedKey in `secret_store` so the unpair
808            // handler can build the encrypted request envelope.
809            for rcid in [99_u64, 100] {
810                rig.channel_store.data.lock().unwrap().insert(
811                    (secret_id, rcid),
812                    Channel {
813                        id: ChannelId(rcid),
814                        transport: TransportProtocol {
815                            uri: format!("https://recovery-{rcid}.example"),
816                            protocol: 0,
817                        },
818                        communication_info: HashMap::new(),
819                        status: ChannelStatus::Paired,
820                        created_at: 1,
821                        role: SenderKind::Owner,
822                        replica_id: None,
823                    },
824                );
825                rig.secret_store.data.lock().unwrap().insert(
826                    (secret_id, rcid, SecretKind::SharedKey as u8),
827                    SecretValue::SharedKey([0x77; 32]),
828                );
829            }
830
831            rig.protocol
832                .restore(&fixture_secret(), 7)
833                .await
834                .expect("restore must succeed despite recovery channels");
835
836            // Recovery channels and their SharedKeys are gone.
837            for rcid in [99_u64, 100] {
838                assert!(
839                    rig.channel_store
840                        .load(secret_id, ChannelId(rcid))
841                        .await
842                        .unwrap()
843                        .is_none(),
844                    "recovery channel {rcid} must be unpaired"
845                );
846                assert!(
847                    rig.secret_store
848                        .load(secret_id, ChannelId(rcid), SecretKind::SharedKey)
849                        .await
850                        .unwrap()
851                        .is_none()
852                );
853            }
854
855            // Canonical state is in place.
856            assert!(
857                rig.channel_store
858                    .load(secret_id, ChannelId(11))
859                    .await
860                    .unwrap()
861                    .is_some()
862            );
863        });
864    }
865
866    // ---------------- Preconditions ----------------
867
868    #[test]
869    fn restore_returns_already_restored_when_snapshot_exists() {
870        run_async(async {
871            let secret_id: u64 = 0xDE_2EC;
872            let mut rig = build_rig(secret_id);
873            rig.user_secret_store.data.lock().unwrap().insert(
874                secret_id,
875                UserSecrets {
876                    version: 1,
877                    secrets: Vec::new(),
878                    description: None,
879                    replicas: None,
880                },
881            );
882
883            let err = rig
884                .protocol
885                .restore(&fixture_secret(), 7)
886                .await
887                .unwrap_err();
888            assert!(matches!(
889                err,
890                crate::Error::Restore(RestoreError::AlreadyRestored)
891            ));
892
893            // No mutation: no canonical channel written.
894            assert!(
895                rig.channel_store
896                    .load(secret_id, ChannelId(11))
897                    .await
898                    .unwrap()
899                    .is_none()
900            );
901        });
902    }
903
904    #[test]
905    fn restore_returns_conflict_on_canonical_id_collision() {
906        run_async(async {
907            let secret_id: u64 = 0xDE_2EC;
908            let mut rig = build_rig(secret_id);
909            // Pre-seed a channel sitting at canonical helper id 11.
910            rig.channel_store.data.lock().unwrap().insert(
911                (secret_id, 11),
912                Channel {
913                    id: ChannelId(11),
914                    transport: TransportProtocol {
915                        uri: "https://collision.example".to_owned(),
916                        protocol: 0,
917                    },
918                    communication_info: HashMap::new(),
919                    status: ChannelStatus::Paired,
920                    created_at: 1,
921                    role: SenderKind::Owner,
922                    replica_id: None,
923                },
924            );
925
926            let err = rig
927                .protocol
928                .restore(&fixture_secret(), 7)
929                .await
930                .unwrap_err();
931            let crate::Error::Restore(RestoreError::Conflict(ids)) = err else {
932                panic!("expected Restore(Conflict), got {err:?}");
933            };
934            assert_eq!(ids, vec![ChannelId(11)]);
935
936            // No mutation beyond the pre-seed.
937            assert!(
938                rig.user_secret_store
939                    .load_latest(secret_id)
940                    .await
941                    .unwrap()
942                    .is_none()
943            );
944        });
945    }
946
947    #[test]
948    fn restore_invariant_error_when_replicas_present_but_group_key_missing() {
949        run_async(async {
950            let secret_id: u64 = 0xDE_2EC;
951            let mut rig = build_rig(secret_id);
952            let mut secret = fixture_secret();
953            if let Some(group) = secret.replicas.as_mut() {
954                group.shared_key = Vec::new();
955            }
956
957            let err = rig.protocol.restore(&secret, 7).await.unwrap_err();
958            assert!(matches!(
959                err,
960                crate::Error::Restore(RestoreError::Invariant(_))
961            ));
962
963            // No mutation.
964            assert!(
965                rig.channel_store
966                    .load(secret_id, ChannelId(11))
967                    .await
968                    .unwrap()
969                    .is_none()
970            );
971        });
972    }
973
974    // ---------------- Explicit replica_id corner ----------------
975
976    #[test]
977    fn restore_does_not_overwrite_explicit_replica_id() {
978        run_async(async {
979            let secret_id: u64 = 0xDE_2EC;
980            // Builder configured WITH a replica id — restore must
981            // leave it alone even though the Secret carries a
982            // non-zero owner_replica_id.
983            let mut protocol = DeRecProtocolBuilder::new(secret_id)
984                .with_channel_store(InMemChannelStore::default())
985                .with_share_store(InMemShareStore::default())
986                .with_secret_store(InMemSecretStore::default())
987                .with_user_secret_store(InMemUserSecretStore::default())
988                .with_transport(NoopTransport)
989            .with_state_store(RestoreTestStateStore)
990                .with_own_transport("https://owner.example.com")
991                .with_threshold(2)
992                .with_replica_id(0x1234)
993                .build()
994                .expect("build");
995
996            protocol.restore(&fixture_secret(), 7).await.unwrap();
997            assert_eq!(protocol.replica_id(), Some(0x1234));
998        });
999    }
1000}