Skip to main content

cookie_agent_models/provider_store/
store.rs

1use std::{collections::BTreeMap, path::Path};
2
3use cookie_agent_identity::{
4    AuthFieldName, AuthMethodId, CatalogRevision, ProviderId, ProviderStateRevision,
5    ProviderStoreRevision, RuntimeRevision, SafeCode, SetupFieldId,
6};
7use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use sha2::{Digest as _, Sha256};
11use uuid::Uuid;
12use zeroize::{Zeroize, Zeroizing};
13
14use crate::{
15    BoundedSetupString, SafeSetupValue, Sha256Digest,
16    secure_store::{DEFAULT_LOCK_BUDGET, SecureDirectory, SecureDirectoryLock},
17};
18
19use super::{
20    PROVIDER_STORE_FILE, PROVIDER_STORE_LOCK_FILE, PROVIDER_STORE_SCHEMA_VERSION,
21    types::{
22        ClientConnectId, ClientRequestId, ConnectMutation, ConnectReceipt, DisconnectMutation,
23        DisconnectReceipt, DurableProviderReceipt, MAX_PROVIDERS, MAX_RECEIPTS, ProviderAuthValues,
24        ProviderConnectionGeneration, ProviderStoreError, ProviderStoreGeneration,
25        ProviderStoreMutation, ProviderStoreSnapshot, SecretValue, StoredManagedConnection,
26        StoredProviderPolicyProjection, validate_setup_values,
27    },
28};
29
30const MAX_STORE_BYTES: u64 = 16 * 1024 * 1024;
31const LEGACY_STORE_FILE: &str = "store-v1.json";
32const PREVIOUS_STORE_FILE: &str = "store-v2.json";
33const UNVERSIONED_STORE_FILE: &str = "store.json";
34
35/// Content stamp of the store file, used by `reload_if_changed` to skip the
36/// lock+decode when the file provably has not changed since the last fully
37/// validated read. `Unknown` (stat or mtime failure) is never cached and
38/// never matches, falling back to the locked read.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40enum StoreFileStamp {
41    Missing,
42    Present {
43        modified: std::time::SystemTime,
44        len: u64,
45    },
46    Unknown,
47}
48
49impl StoreFileStamp {
50    fn current(path: &Path) -> Self {
51        match std::fs::metadata(path) {
52            Ok(metadata) => match metadata.modified() {
53                Ok(modified) => Self::Present {
54                    modified,
55                    len: metadata.len(),
56                },
57                Err(_) => Self::Unknown,
58            },
59            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::Missing,
60            Err(_) => Self::Unknown,
61        }
62    }
63}
64
65/// Last fully validated read: file stamp plus the generation it decodes to.
66#[derive(Clone, Copy, Debug)]
67struct ReloadStamp {
68    file: StoreFileStamp,
69    generation: ProviderStoreGeneration,
70}
71
72/// Secure provider-store handle rooted at one private directory.
73#[derive(Debug)]
74pub struct ProviderStore {
75    directory: SecureDirectory,
76    reload_stamp: std::sync::Mutex<Option<ReloadStamp>>,
77    #[cfg(test)]
78    transaction_opens: std::sync::atomic::AtomicU64,
79}
80
81impl ProviderStore {
82    /// Opens the approved fixed per-user path.
83    pub fn standard() -> Result<Self, ProviderStoreError> {
84        Self::from_directory(SecureDirectory::user_data("providers")?)
85    }
86
87    /// Opens or creates an absolute private provider-store directory.
88    pub fn open(path: impl AsRef<Path>) -> Result<Self, ProviderStoreError> {
89        Self::from_directory(SecureDirectory::open(path)?)
90    }
91
92    /// Opens a private provider-store directory below a trusted anchor.
93    pub fn open_in(
94        anchor: impl AsRef<Path>,
95        relative: impl AsRef<Path>,
96    ) -> Result<Self, ProviderStoreError> {
97        Self::from_directory(SecureDirectory::open_in(anchor, relative)?)
98    }
99
100    fn from_directory(directory: SecureDirectory) -> Result<Self, ProviderStoreError> {
101        Ok(Self {
102            directory,
103            reload_stamp: std::sync::Mutex::new(None),
104            #[cfg(test)]
105            transaction_opens: std::sync::atomic::AtomicU64::new(0),
106        })
107    }
108
109    #[must_use]
110    pub fn path(&self) -> &Path {
111        self.directory.path()
112    }
113
114    /// Locks and rereads the complete store.
115    pub fn load(&self) -> Result<ProviderStoreSnapshot, ProviderStoreError> {
116        Ok(self.begin_transaction()?.state.snapshot())
117    }
118
119    /// Locks and rereads, returning state only when another process changed generation.
120    ///
121    /// A matching cached content stamp proves the file is byte-identical to
122    /// the last fully validated read, so the per-run-start reconcile skips the
123    /// lock+decode entirely. The stamp is refreshed under the lock after every
124    /// real read; a cross-process commit between the stat and the comparison
125    /// is picked up by the next reconcile.
126    pub fn reload_if_changed(
127        &self,
128        known: ProviderStoreGeneration,
129    ) -> Result<Option<ProviderStoreSnapshot>, ProviderStoreError> {
130        let file_path = self.directory.path().join(PROVIDER_STORE_FILE);
131        let current_stamp = StoreFileStamp::current(&file_path);
132        if !matches!(current_stamp, StoreFileStamp::Unknown) {
133            let stamp = self
134                .reload_stamp
135                .lock()
136                .unwrap_or_else(|poisoned| poisoned.into_inner());
137            if let Some(cached) = *stamp
138                && cached.generation == known
139                && cached.file == current_stamp
140            {
141                return Ok(None);
142            }
143        }
144        let transaction = self.begin_transaction()?;
145        // The stamp was captured under the read lock inside `begin_transaction`
146        // and describes exactly the state that was read.
147        let post_read_stamp = transaction.file_stamp;
148        if !matches!(post_read_stamp, StoreFileStamp::Unknown) {
149            *self
150                .reload_stamp
151                .lock()
152                .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(ReloadStamp {
153                file: post_read_stamp,
154                generation: transaction.state.generation,
155            });
156        }
157        if transaction.state.generation == known {
158            Ok(None)
159        } else {
160            Ok(Some(transaction.state.snapshot()))
161        }
162    }
163
164    /// Starts a bounded lock+reread transaction.
165    ///
166    /// The lock is held only for the reread and released before returning, so
167    /// proposal compilation, runtime compilation, and the publication callback
168    /// all run lock-free. `commit` re-acquires the lock and compare-and-swaps
169    /// the base stamp captured here against the live on-disk state.
170    pub fn begin_transaction(&self) -> Result<ProviderStoreTransaction<'_>, ProviderStoreError> {
171        #[cfg(test)]
172        self.transaction_opens
173            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
174        let lock = self
175            .directory
176            .lock_within(PROVIDER_STORE_LOCK_FILE, DEFAULT_LOCK_BUDGET)?;
177        reject_obsolete_files(&lock)?;
178        let state = match lock.read(PROVIDER_STORE_FILE, MAX_STORE_BYTES)? {
179            Some(bytes) => {
180                let bytes = Zeroizing::new(bytes);
181                decode_state(bytes.as_ref())?
182            }
183            None => StoreState::fresh()?,
184        };
185        // Capture the content stamp of the state actually read while the read
186        // lock is still held, so `reload_if_changed` caches a stamp that
187        // describes this read rather than a later, post-release observation.
188        let file_stamp = StoreFileStamp::current(&self.directory.path().join(PROVIDER_STORE_FILE));
189        drop(lock);
190        Ok(ProviderStoreTransaction {
191            directory: &self.directory,
192            state,
193            file_stamp,
194            transaction_id: Uuid::now_v7(),
195        })
196    }
197}
198
199/// Provider-store transaction used to propose, compile, then commit exactly one state.
200///
201/// The transaction holds no file lock; `state` is the base stamp that `commit`
202/// validates against a fresh on-disk reread before replacing the store.
203pub struct ProviderStoreTransaction<'a> {
204    directory: &'a SecureDirectory,
205    state: StoreState,
206    file_stamp: StoreFileStamp,
207    transaction_id: Uuid,
208}
209
210impl std::fmt::Debug for ProviderStoreTransaction<'_> {
211    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        formatter
213            .debug_struct("ProviderStoreTransaction")
214            .field("generation", &self.state.generation)
215            .field("store_revision", &self.state.store_revision)
216            .finish_non_exhaustive()
217    }
218}
219
220impl ProviderStoreTransaction<'_> {
221    #[must_use]
222    pub fn snapshot(&self) -> ProviderStoreSnapshot {
223        self.state.snapshot()
224    }
225
226    /// Validates replay/conflict/revisions and allocates the final connect proposal.
227    pub fn propose_connect(
228        &self,
229        request: &ConnectMutation,
230        current_catalog_revision: &CatalogRevision,
231    ) -> Result<ConnectProposal, ProviderStoreError> {
232        validate_setup_values(&request.setup_values)?;
233        request.policy.validate()?;
234
235        let payload_digest = connect_payload_digest(request)?;
236
237        if let Some(stored) = self.state.connect_receipts.get(&request.client_connect_id) {
238            if !digest_equal(&stored.payload_digest, &payload_digest) {
239                return Err(ProviderStoreError::IdempotencyConflict);
240            }
241            return Ok(ConnectProposal::Replay(Box::new(
242                ProviderStoreMutation::Connect {
243                    durable_receipt: stored.result.durable_receipt.clone(),
244                    durable_connection: stored.result.durable_connection.clone(),
245                },
246            )));
247        }
248
249        if &request.expected_catalog_revision != current_catalog_revision
250            || &request.policy.catalog_revision != current_catalog_revision
251        {
252            return Err(ProviderStoreError::CatalogRevisionConflict);
253        }
254        validate_expectation(&self.state, &request.expectation)?;
255
256        let mut candidate = self.state.clone();
257        candidate.generation = candidate.generation.checked_next()?;
258        let committed_at = Timestamp::now();
259        let connection_generation = self
260            .state
261            .providers
262            .get(&request.provider_id)
263            .map_or(Ok(ProviderConnectionGeneration::new(1)?), |connection| {
264                connection.connection_generation.checked_next()
265            })?;
266        let setup_fingerprint = setup_fingerprint(&request.setup_values)?;
267        let connection = StoredManagedConnection {
268            provider_id: request.provider_id.clone(),
269            setup_values: request.setup_values.clone(),
270            setup_fingerprint,
271            auth_method: request.auth_method.clone(),
272            auth_values: request.auth_values.clone(),
273            connection_generation,
274            policy: request.policy.clone(),
275            connected_at: committed_at,
276        };
277        let receipt = ConnectReceipt {
278            durable_receipt: DurableProviderReceipt {
279                receipt_id: Uuid::now_v7(),
280                store_revision: placeholder_revision(),
281                provider_state_revision: provider_state_revision(&placeholder_revision()),
282                committed_at,
283            },
284            durable_connection: connection.descriptor(),
285        };
286        candidate
287            .providers
288            .insert(request.provider_id.clone(), connection);
289        if candidate.providers.len() > MAX_PROVIDERS
290            || candidate.connect_receipts.len() >= MAX_RECEIPTS
291        {
292            return Err(ProviderStoreError::InvalidRequest);
293        }
294        candidate.connect_receipts.insert(
295            request.client_connect_id.clone(),
296            StoredConnectReceipt {
297                payload_digest,
298                result: receipt.clone(),
299            },
300        );
301        candidate.finalize_revision_for_connect(&request.client_connect_id)?;
302        let receipt = candidate
303            .connect_receipts
304            .get(&request.client_connect_id)
305            .expect("inserted connect receipt")
306            .result
307            .clone();
308        let proposal = ProposedProviderStore::new(
309            self.transaction_id,
310            &self.state,
311            candidate,
312            ProviderStoreMutation::Connect {
313                durable_receipt: receipt.durable_receipt,
314                durable_connection: receipt.durable_connection,
315            },
316        )?;
317        Ok(ConnectProposal::Proposed(Box::new(proposal)))
318    }
319
320    /// Validates replay/conflict/revisions/generation and allocates the final disconnect proposal.
321    pub fn propose_disconnect(
322        &self,
323        request: &DisconnectMutation,
324        current_runtime_revision: &RuntimeRevision,
325    ) -> Result<DisconnectProposal, ProviderStoreError> {
326        let payload_digest = disconnect_payload_digest(request)?;
327        if let Some(stored) = self
328            .state
329            .disconnect_receipts
330            .get(&request.client_request_id)
331        {
332            if !digest_equal(&stored.payload_digest, &payload_digest) {
333                return Err(ProviderStoreError::IdempotencyConflict);
334            }
335            return Ok(DisconnectProposal::Replay(Box::new(
336                ProviderStoreMutation::Disconnect {
337                    durable_receipt: stored.result.durable_receipt.clone(),
338                    provider_id: stored.result.provider_id.clone(),
339                    disconnected: stored.result.disconnected,
340                },
341            )));
342        }
343
344        if &request.expected_runtime_revision != current_runtime_revision {
345            return Err(ProviderStoreError::RuntimeRevisionConflict);
346        }
347        if request.expected_store_generation != self.state.generation {
348            return Err(ProviderStoreError::StoreGenerationConflict);
349        }
350        if request.expected_store_revision != self.state.store_revision {
351            return Err(ProviderStoreError::StoreRevisionConflict);
352        }
353        if request.expected_provider_state_revision
354            != provider_state_revision(&self.state.store_revision)
355        {
356            return Err(ProviderStoreError::ProviderStateRevisionConflict);
357        }
358        match (
359            self.state.providers.get(&request.provider_id),
360            request.expected_connection_generation,
361        ) {
362            (Some(connection), Some(expected)) if connection.connection_generation == expected => {}
363            (None, None) => {}
364            _ => return Err(ProviderStoreError::StaleConnectionGeneration),
365        }
366
367        let mut candidate = self.state.clone();
368        candidate.generation = candidate.generation.checked_next()?;
369        candidate.providers.remove(&request.provider_id);
370        if candidate.disconnect_receipts.len() >= MAX_RECEIPTS {
371            return Err(ProviderStoreError::InvalidRequest);
372        }
373        let committed_at = Timestamp::now();
374        let result = DisconnectReceipt {
375            durable_receipt: DurableProviderReceipt {
376                receipt_id: Uuid::now_v7(),
377                store_revision: placeholder_revision(),
378                provider_state_revision: provider_state_revision(&placeholder_revision()),
379                committed_at,
380            },
381            provider_id: request.provider_id.clone(),
382            disconnected: true,
383        };
384        candidate.disconnect_receipts.insert(
385            request.client_request_id.clone(),
386            StoredDisconnectReceipt {
387                payload_digest,
388                result: result.clone(),
389            },
390        );
391        candidate.finalize_revision_for_disconnect(&request.client_request_id)?;
392        let result = candidate
393            .disconnect_receipts
394            .get(&request.client_request_id)
395            .expect("inserted disconnect receipt")
396            .result
397            .clone();
398        let proposal = ProposedProviderStore::new(
399            self.transaction_id,
400            &self.state,
401            candidate,
402            ProviderStoreMutation::Disconnect {
403                durable_receipt: result.durable_receipt,
404                provider_id: result.provider_id,
405                disconnected: result.disconnected,
406            },
407        )?;
408        Ok(DisconnectProposal::Proposed(Box::new(proposal)))
409    }
410
411    /// Atomically commits the exact pre-serialized proposal. No revision is allocated here.
412    ///
413    /// The commit re-acquires the store lock, rereads the on-disk state, and
414    /// compare-and-swaps the live `generation`/`store_revision` against the
415    /// proposal's recorded base. A concurrent commit that landed first yields
416    /// [`ProviderStoreError::Stale`] and leaves the disk untouched.
417    pub fn commit(
418        self,
419        proposal: ProposedProviderStore,
420    ) -> Result<CommittedProviderStore, ProviderStoreError> {
421        if proposal.transaction_id != self.transaction_id
422            || proposal.base_generation != self.state.generation
423            || proposal.base_revision != self.state.store_revision
424        {
425            return Err(ProviderStoreError::ProposalMismatch);
426        }
427        // Re-acquire the lock only for the compare-and-swap and the replace;
428        // compile and publication already ran lock-free.
429        let lock = self
430            .directory
431            .lock_within(PROVIDER_STORE_LOCK_FILE, DEFAULT_LOCK_BUDGET)?;
432        let live = match lock.read(PROVIDER_STORE_FILE, MAX_STORE_BYTES)? {
433            Some(bytes) => {
434                let bytes = Zeroizing::new(bytes);
435                decode_state(bytes.as_ref())?
436            }
437            None => StoreState::fresh()?,
438        };
439        if live.generation != proposal.base_generation
440            || live.store_revision != proposal.base_revision
441        {
442            drop(lock);
443            return Err(ProviderStoreError::Stale);
444        }
445        // Allocate the complete safe return value before the durable write. After replacement,
446        // commit only moves prebuilt data so manager publication remains infallible.
447        let snapshot = proposal.state.snapshot();
448        let mutation = proposal.mutation.clone();
449        self.directory
450            .atomic_replace(PROVIDER_STORE_FILE, proposal.bytes.as_ref())?;
451        drop(lock);
452        Ok(CommittedProviderStore { snapshot, mutation })
453    }
454}
455
456/// Connect proposal outcome. Replays require neither compilation nor publication.
457#[derive(Debug)]
458pub enum ConnectProposal {
459    Replay(Box<ProviderStoreMutation>),
460    Proposed(Box<ProposedProviderStore>),
461}
462
463/// Disconnect proposal outcome. Replays require neither compilation nor publication.
464#[derive(Debug)]
465pub enum DisconnectProposal {
466    Replay(Box<ProviderStoreMutation>),
467    Proposed(Box<ProposedProviderStore>),
468}
469
470/// Final proposed state and bytes compiled by P6 before commit.
471pub struct ProposedProviderStore {
472    transaction_id: Uuid,
473    base_generation: ProviderStoreGeneration,
474    base_revision: ProviderStoreRevision,
475    state: StoreState,
476    bytes: Zeroizing<Vec<u8>>,
477    mutation: ProviderStoreMutation,
478}
479
480impl ProposedProviderStore {
481    fn new(
482        transaction_id: Uuid,
483        base: &StoreState,
484        state: StoreState,
485        mutation: ProviderStoreMutation,
486    ) -> Result<Self, ProviderStoreError> {
487        state.validate()?;
488        let bytes = Zeroizing::new(encode_state(&state)?);
489        if bytes.len() as u64 > MAX_STORE_BYTES {
490            return Err(ProviderStoreError::InvalidStore);
491        }
492        Ok(Self {
493            transaction_id,
494            base_generation: base.generation,
495            base_revision: base.store_revision.clone(),
496            state,
497            bytes,
498            mutation,
499        })
500    }
501
502    #[must_use]
503    pub fn snapshot(&self) -> ProviderStoreSnapshot {
504        self.state.snapshot()
505    }
506
507    #[must_use]
508    pub fn mutation(&self) -> &ProviderStoreMutation {
509        &self.mutation
510    }
511}
512
513impl std::fmt::Debug for ProposedProviderStore {
514    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
515        formatter
516            .debug_struct("ProposedProviderStore")
517            .field("base_generation", &self.base_generation)
518            .field("base_revision", &self.base_revision)
519            .field("generation", &self.state.generation)
520            .field("store_revision", &self.state.store_revision)
521            .field("mutation", &self.mutation)
522            .finish()
523    }
524}
525
526/// Durable result of committing a proposal.
527#[derive(Debug)]
528pub struct CommittedProviderStore {
529    pub snapshot: ProviderStoreSnapshot,
530    pub mutation: ProviderStoreMutation,
531}
532
533#[derive(Clone)]
534struct StoreState {
535    generation: ProviderStoreGeneration,
536    store_revision: ProviderStoreRevision,
537    providers: BTreeMap<ProviderId, StoredManagedConnection>,
538    connect_receipts: BTreeMap<ClientConnectId, StoredConnectReceipt>,
539    disconnect_receipts: BTreeMap<ClientRequestId, StoredDisconnectReceipt>,
540}
541
542impl StoreState {
543    fn fresh() -> Result<Self, ProviderStoreError> {
544        let mut state = Self {
545            generation: ProviderStoreGeneration::new(1).expect("valid initial generation"),
546            store_revision: placeholder_revision(),
547            providers: BTreeMap::new(),
548            connect_receipts: BTreeMap::new(),
549            disconnect_receipts: BTreeMap::new(),
550        };
551        state.store_revision = state_revision(&state)?;
552        Ok(state)
553    }
554
555    fn finalize_revision_for_connect(
556        &mut self,
557        id: &ClientConnectId,
558    ) -> Result<(), ProviderStoreError> {
559        let revision = state_revision(self)?;
560        self.store_revision = revision.clone();
561        let receipt = self
562            .connect_receipts
563            .get_mut(id)
564            .ok_or(ProviderStoreError::InvalidStore)?;
565        receipt.result.durable_receipt.store_revision = revision.clone();
566        receipt.result.durable_receipt.provider_state_revision = provider_state_revision(&revision);
567        Ok(())
568    }
569
570    fn finalize_revision_for_disconnect(
571        &mut self,
572        id: &ClientRequestId,
573    ) -> Result<(), ProviderStoreError> {
574        let revision = state_revision(self)?;
575        self.store_revision = revision.clone();
576        let receipt = self
577            .disconnect_receipts
578            .get_mut(id)
579            .ok_or(ProviderStoreError::InvalidStore)?;
580        receipt.result.durable_receipt.store_revision = revision.clone();
581        receipt.result.durable_receipt.provider_state_revision = provider_state_revision(&revision);
582        Ok(())
583    }
584
585    fn snapshot(&self) -> ProviderStoreSnapshot {
586        ProviderStoreSnapshot {
587            generation: self.generation,
588            store_revision: self.store_revision.clone(),
589            providers: self.providers.clone(),
590            connect_receipts: self
591                .connect_receipts
592                .iter()
593                .map(|(id, receipt)| (id.clone(), receipt.result.clone()))
594                .collect(),
595            disconnect_receipts: self
596                .disconnect_receipts
597                .iter()
598                .map(|(id, receipt)| (id.clone(), receipt.result.clone()))
599                .collect(),
600        }
601    }
602
603    fn validate(&self) -> Result<(), ProviderStoreError> {
604        if self.providers.len() > MAX_PROVIDERS
605            || self.connect_receipts.len() > MAX_RECEIPTS
606            || self.disconnect_receipts.len() > MAX_RECEIPTS
607        {
608            return Err(ProviderStoreError::InvalidStore);
609        }
610        if state_revision(self)? != self.store_revision {
611            return Err(ProviderStoreError::InvalidStore);
612        }
613        for (provider_id, connection) in &self.providers {
614            if provider_id != &connection.provider_id {
615                return Err(ProviderStoreError::InvalidStore);
616            }
617            validate_setup_values(&connection.setup_values)
618                .map_err(|_| ProviderStoreError::InvalidStore)?;
619            if setup_fingerprint(&connection.setup_values)? != connection.setup_fingerprint {
620                return Err(ProviderStoreError::InvalidStore);
621            }
622            connection.policy.validate()?;
623        }
624        for receipt in self.connect_receipts.values() {
625            validate_setup_values(&receipt.result.durable_connection.setup_values)
626                .map_err(|_| ProviderStoreError::InvalidStore)?;
627            if receipt.result.durable_connection.setup_fingerprint
628                != setup_fingerprint(&receipt.result.durable_connection.setup_values)?
629                || receipt.result.durable_connection.credential_fields.len()
630                    > super::types::MAX_AUTH_FIELDS
631                || receipt
632                    .result
633                    .durable_connection
634                    .credential_fields
635                    .windows(2)
636                    .any(|pair| pair[0] >= pair[1])
637                || receipt.result.durable_receipt.provider_state_revision
638                    != provider_state_revision(&receipt.result.durable_receipt.store_revision)
639            {
640                return Err(ProviderStoreError::InvalidStore);
641            }
642        }
643        for receipt in self.disconnect_receipts.values() {
644            if !receipt.result.disconnected
645                || receipt.result.durable_receipt.provider_state_revision
646                    != provider_state_revision(&receipt.result.durable_receipt.store_revision)
647            {
648                return Err(ProviderStoreError::InvalidStore);
649            }
650        }
651        if (!self.connect_receipts.is_empty() || !self.disconnect_receipts.is_empty())
652            && !self
653                .connect_receipts
654                .values()
655                .map(|receipt| &receipt.result.durable_receipt.store_revision)
656                .chain(
657                    self.disconnect_receipts
658                        .values()
659                        .map(|receipt| &receipt.result.durable_receipt.store_revision),
660                )
661                .any(|revision| revision == &self.store_revision)
662        {
663            return Err(ProviderStoreError::InvalidStore);
664        }
665        Ok(())
666    }
667}
668
669#[derive(Clone)]
670struct StoredConnectReceipt {
671    payload_digest: Sha256Digest,
672    result: ConnectReceipt,
673}
674
675#[derive(Clone)]
676struct StoredDisconnectReceipt {
677    payload_digest: Sha256Digest,
678    result: DisconnectReceipt,
679}
680
681#[derive(Serialize)]
682#[serde(deny_unknown_fields)]
683struct ConnectPayload<'a> {
684    client_connect_id: &'a ClientConnectId,
685    provider_id: &'a ProviderId,
686    expected_catalog_revision: &'a CatalogRevision,
687    setup_values: &'a BTreeMap<SetupFieldId, SafeSetupValue>,
688    auth_method: &'a AuthMethodId,
689    auth_values: SecretMapRef<'a>,
690    policy: &'a StoredProviderPolicyProjection,
691}
692
693#[derive(Clone, Copy)]
694struct SecretMapRef<'a>(&'a ProviderAuthValues);
695
696impl Serialize for SecretMapRef<'_> {
697    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
698    where
699        S: serde::Serializer,
700    {
701        use serde::ser::SerializeMap as _;
702
703        let mut map = serializer.serialize_map(Some(self.0.0.len()))?;
704        for (field, value) in &self.0.0 {
705            map.serialize_entry(field, value.expose())?;
706        }
707        map.end()
708    }
709}
710
711#[derive(Serialize)]
712#[serde(deny_unknown_fields)]
713struct DisconnectPayload<'a> {
714    client_request_id: &'a ClientRequestId,
715    provider_id: &'a ProviderId,
716    expected_runtime_revision: &'a RuntimeRevision,
717    expected_provider_state_revision: &'a ProviderStateRevision,
718    expected_connection_generation: Option<ProviderConnectionGeneration>,
719}
720
721fn connect_payload_digest(request: &ConnectMutation) -> Result<Sha256Digest, ProviderStoreError> {
722    sha256_jcs(&ConnectPayload {
723        client_connect_id: &request.client_connect_id,
724        provider_id: &request.provider_id,
725        expected_catalog_revision: &request.expected_catalog_revision,
726        setup_values: &request.setup_values,
727        auth_method: &request.auth_method,
728        auth_values: SecretMapRef(&request.auth_values),
729        policy: &request.policy,
730    })
731}
732
733fn disconnect_payload_digest(
734    request: &DisconnectMutation,
735) -> Result<Sha256Digest, ProviderStoreError> {
736    sha256_jcs(&DisconnectPayload {
737        client_request_id: &request.client_request_id,
738        provider_id: &request.provider_id,
739        expected_runtime_revision: &request.expected_runtime_revision,
740        expected_provider_state_revision: &request.expected_provider_state_revision,
741        expected_connection_generation: request.expected_connection_generation,
742    })
743}
744
745fn reject_obsolete_files(lock: &SecureDirectoryLock<'_>) -> Result<(), ProviderStoreError> {
746    if lock.read(LEGACY_STORE_FILE, MAX_STORE_BYTES)?.is_some()
747        || lock.read(PREVIOUS_STORE_FILE, MAX_STORE_BYTES)?.is_some()
748    {
749        return Err(ProviderStoreError::LegacyStoreVersion);
750    }
751    if lock
752        .read(UNVERSIONED_STORE_FILE, MAX_STORE_BYTES)?
753        .is_some()
754    {
755        return Err(ProviderStoreError::UnversionedStore);
756    }
757    Ok(())
758}
759
760fn validate_expectation(
761    state: &StoreState,
762    expectation: &super::types::ProviderStoreExpectation,
763) -> Result<(), ProviderStoreError> {
764    if expectation.generation != state.generation {
765        return Err(ProviderStoreError::StoreGenerationConflict);
766    }
767    if expectation.store_revision != state.store_revision {
768        return Err(ProviderStoreError::StoreRevisionConflict);
769    }
770    if expectation.provider_state_revision != provider_state_revision(&state.store_revision) {
771        return Err(ProviderStoreError::ProviderStateRevisionConflict);
772    }
773    Ok(())
774}
775
776pub(crate) fn setup_fingerprint(
777    setup: &BTreeMap<SetupFieldId, SafeSetupValue>,
778) -> Result<Sha256Digest, ProviderStoreError> {
779    sha256_jcs(&disk_setup_values(setup))
780}
781
782fn sha256_jcs(value: &impl Serialize) -> Result<Sha256Digest, ProviderStoreError> {
783    let bytes = jcs_bytes(value)?;
784    let mut hasher = Sha256::new();
785    hasher.update(&*bytes);
786    Sha256Digest::new(format!("{:x}", hasher.finalize())).map_err(|_| ProviderStoreError::Encoding)
787}
788
789fn digest_equal(left: &Sha256Digest, right: &Sha256Digest) -> bool {
790    left.as_str()
791        .bytes()
792        .zip(right.as_str().bytes())
793        .fold(0_u8, |difference, (left, right)| {
794            difference | (left ^ right)
795        })
796        == 0
797}
798
799fn placeholder_revision() -> ProviderStoreRevision {
800    let digest = Sha256::digest([]);
801    ProviderStoreRevision::new(format!("sha256:{digest:x}")).expect("SHA-256 is a valid revision")
802}
803
804fn state_revision(state: &StoreState) -> Result<ProviderStoreRevision, ProviderStoreError> {
805    let digest = sha256_jcs(&RevisionState::from(state))?;
806    ProviderStoreRevision::new(format!("sha256:{}", digest.as_str()))
807        .map_err(|_| ProviderStoreError::Encoding)
808}
809
810fn provider_state_revision(revision: &ProviderStoreRevision) -> ProviderStateRevision {
811    ProviderStateRevision::new(revision.as_str().to_owned())
812        .expect("provider-store revision is a provider-state revision")
813}
814
815// Exact durable-state projection used for the revision. Only the self-referential top-level and
816// receipt revision fields are omitted; every other persisted field participates in the JCS hash.
817#[derive(Serialize)]
818#[serde(deny_unknown_fields)]
819struct RevisionState {
820    schema_version: u32,
821    generation: ProviderStoreGeneration,
822    providers: BTreeMap<ProviderId, DiskConnection>,
823    connect_receipts: BTreeMap<ClientConnectId, RevisionConnectReceipt>,
824    disconnect_receipts: BTreeMap<ClientRequestId, RevisionDisconnectReceipt>,
825}
826
827#[derive(Serialize)]
828#[serde(deny_unknown_fields)]
829struct RevisionConnectReceipt {
830    payload_digest: Sha256Digest,
831    result: RevisionConnectResult,
832}
833
834#[derive(Serialize)]
835#[serde(deny_unknown_fields)]
836struct RevisionConnectResult {
837    durable_receipt: RevisionDurableReceipt,
838    durable_connection: DiskConnectionDescriptor,
839}
840
841#[derive(Serialize)]
842#[serde(deny_unknown_fields)]
843struct RevisionDisconnectReceipt {
844    payload_digest: Sha256Digest,
845    result: RevisionDisconnectResult,
846}
847
848#[derive(Serialize)]
849#[serde(deny_unknown_fields)]
850struct RevisionDisconnectResult {
851    durable_receipt: RevisionDurableReceipt,
852    provider_id: ProviderId,
853    disconnected: bool,
854}
855
856#[derive(Serialize)]
857#[serde(deny_unknown_fields)]
858struct RevisionDurableReceipt {
859    receipt_id: Uuid,
860    committed_at: Timestamp,
861}
862
863impl From<&StoreState> for RevisionState {
864    fn from(state: &StoreState) -> Self {
865        Self {
866            schema_version: PROVIDER_STORE_SCHEMA_VERSION,
867            generation: state.generation,
868            providers: state
869                .providers
870                .iter()
871                .map(|(id, connection)| (id.clone(), DiskConnection::from(connection)))
872                .collect(),
873            connect_receipts: state
874                .connect_receipts
875                .iter()
876                .map(|(id, receipt)| {
877                    (
878                        id.clone(),
879                        RevisionConnectReceipt {
880                            payload_digest: receipt.payload_digest.clone(),
881                            result: RevisionConnectResult {
882                                durable_receipt: RevisionDurableReceipt::from(
883                                    &receipt.result.durable_receipt,
884                                ),
885                                durable_connection: DiskConnectionDescriptor::from(
886                                    &receipt.result.durable_connection,
887                                ),
888                            },
889                        },
890                    )
891                })
892                .collect(),
893            disconnect_receipts: state
894                .disconnect_receipts
895                .iter()
896                .map(|(id, receipt)| {
897                    (
898                        id.clone(),
899                        RevisionDisconnectReceipt {
900                            payload_digest: receipt.payload_digest.clone(),
901                            result: RevisionDisconnectResult {
902                                durable_receipt: RevisionDurableReceipt::from(
903                                    &receipt.result.durable_receipt,
904                                ),
905                                provider_id: receipt.result.provider_id.clone(),
906                                disconnected: receipt.result.disconnected,
907                            },
908                        },
909                    )
910                })
911                .collect(),
912        }
913    }
914}
915
916impl From<&DurableProviderReceipt> for RevisionDurableReceipt {
917    fn from(receipt: &DurableProviderReceipt) -> Self {
918        Self {
919            receipt_id: receipt.receipt_id,
920            committed_at: receipt.committed_at,
921        }
922    }
923}
924
925#[derive(Serialize, Deserialize)]
926#[serde(deny_unknown_fields)]
927struct DiskState {
928    schema_version: u32,
929    generation: ProviderStoreGeneration,
930    store_revision: ProviderStoreRevision,
931    providers: BTreeMap<ProviderId, DiskConnection>,
932    connect_receipts: BTreeMap<ClientConnectId, DiskConnectReceipt>,
933    disconnect_receipts: BTreeMap<ClientRequestId, DiskDisconnectReceipt>,
934}
935
936#[derive(Serialize, Deserialize)]
937#[serde(deny_unknown_fields)]
938struct DiskConnection {
939    provider_id: ProviderId,
940    setup_values: BTreeMap<SetupFieldId, DiskSetupValue>,
941    setup_fingerprint: Sha256Digest,
942    auth_method: AuthMethodId,
943    auth_values: BTreeMap<AuthFieldName, DiskSecret>,
944    connection_generation: ProviderConnectionGeneration,
945    policy: StoredProviderPolicyProjection,
946    connected_at: Timestamp,
947}
948
949#[derive(Serialize, Deserialize)]
950#[serde(deny_unknown_fields)]
951struct DiskConnectReceipt {
952    payload_digest: Sha256Digest,
953    result: DiskConnectResult,
954}
955
956#[derive(Clone, Serialize, Deserialize)]
957#[serde(deny_unknown_fields)]
958struct DiskConnectResult {
959    durable_receipt: DiskDurableReceipt,
960    durable_connection: DiskConnectionDescriptor,
961}
962
963#[derive(Clone, Serialize, Deserialize)]
964#[serde(deny_unknown_fields)]
965struct DiskDisconnectReceipt {
966    payload_digest: Sha256Digest,
967    result: DiskDisconnectResult,
968}
969
970#[derive(Clone, Serialize, Deserialize)]
971#[serde(deny_unknown_fields)]
972struct DiskDisconnectResult {
973    durable_receipt: DiskDurableReceipt,
974    provider_id: ProviderId,
975    disconnected: bool,
976}
977
978#[derive(Clone, Serialize, Deserialize)]
979#[serde(deny_unknown_fields)]
980struct DiskDurableReceipt {
981    receipt_id: Uuid,
982    store_revision: ProviderStoreRevision,
983    provider_state_revision: ProviderStateRevision,
984    committed_at: Timestamp,
985}
986
987#[derive(Clone, Serialize, Deserialize)]
988#[serde(deny_unknown_fields)]
989struct DiskConnectionDescriptor {
990    provider_id: ProviderId,
991    setup_values: BTreeMap<SetupFieldId, DiskSetupValue>,
992    setup_fingerprint: Sha256Digest,
993    recipe_fingerprint: Sha256Digest,
994    auth_method: AuthMethodId,
995    credential_fields: Vec<AuthFieldName>,
996    connection_generation: ProviderConnectionGeneration,
997    connected_at: Timestamp,
998}
999
1000#[derive(Serialize, Deserialize)]
1001#[serde(transparent)]
1002struct DiskSecret(String);
1003
1004#[derive(Clone, Serialize, Deserialize)]
1005#[serde(
1006    tag = "type",
1007    content = "value",
1008    rename_all = "snake_case",
1009    deny_unknown_fields
1010)]
1011enum DiskSetupValue {
1012    String(BoundedSetupString),
1013    Code(SafeCode),
1014    Integer(i64),
1015    Bool(bool),
1016}
1017
1018impl From<&SafeSetupValue> for DiskSetupValue {
1019    fn from(value: &SafeSetupValue) -> Self {
1020        match value {
1021            SafeSetupValue::String(value) => Self::String(value.clone()),
1022            SafeSetupValue::Code(value) => Self::Code(value.clone()),
1023            SafeSetupValue::Integer(value) => Self::Integer(*value),
1024            SafeSetupValue::Bool(value) => Self::Bool(*value),
1025        }
1026    }
1027}
1028
1029impl From<DiskSetupValue> for SafeSetupValue {
1030    fn from(value: DiskSetupValue) -> Self {
1031        match value {
1032            DiskSetupValue::String(value) => Self::String(value),
1033            DiskSetupValue::Code(value) => Self::Code(value),
1034            DiskSetupValue::Integer(value) => Self::Integer(value),
1035            DiskSetupValue::Bool(value) => Self::Bool(value),
1036        }
1037    }
1038}
1039
1040fn disk_setup_values(
1041    values: &BTreeMap<SetupFieldId, SafeSetupValue>,
1042) -> BTreeMap<SetupFieldId, DiskSetupValue> {
1043    values
1044        .iter()
1045        .map(|(field, value)| (field.clone(), DiskSetupValue::from(value)))
1046        .collect()
1047}
1048
1049fn live_setup_values(
1050    values: BTreeMap<SetupFieldId, DiskSetupValue>,
1051) -> BTreeMap<SetupFieldId, SafeSetupValue> {
1052    values
1053        .into_iter()
1054        .map(|(field, value)| (field, SafeSetupValue::from(value)))
1055        .collect()
1056}
1057
1058impl DiskSecret {
1059    fn into_secret(mut self) -> Result<SecretValue, ProviderStoreError> {
1060        SecretValue::new(std::mem::take(&mut self.0)).map_err(|_| ProviderStoreError::InvalidStore)
1061    }
1062}
1063
1064impl Drop for DiskSecret {
1065    fn drop(&mut self) {
1066        self.0.zeroize();
1067    }
1068}
1069
1070impl From<&SecretValue> for DiskSecret {
1071    fn from(value: &SecretValue) -> Self {
1072        Self(value.expose().to_owned())
1073    }
1074}
1075
1076fn encode_state(state: &StoreState) -> Result<Vec<u8>, ProviderStoreError> {
1077    let disk = DiskState::from(state);
1078    let mut bytes = jcs_bytes(&disk)?;
1079    Ok(std::mem::take(&mut *bytes))
1080}
1081
1082fn jcs_bytes(value: &impl Serialize) -> Result<Zeroizing<Vec<u8>>, ProviderStoreError> {
1083    let value = StrictValue(serde_json::to_value(value).map_err(|_| ProviderStoreError::Encoding)?);
1084    let mut bytes = Zeroizing::new(Vec::new());
1085    write_jcs(&value.0, &mut bytes)?;
1086    Ok(bytes)
1087}
1088
1089fn write_jcs(value: &Value, output: &mut Vec<u8>) -> Result<(), ProviderStoreError> {
1090    match value {
1091        Value::Null => output.extend_from_slice(b"null"),
1092        Value::Bool(true) => output.extend_from_slice(b"true"),
1093        Value::Bool(false) => output.extend_from_slice(b"false"),
1094        Value::Number(number) => {
1095            if number.as_i64().is_none() && number.as_u64().is_none() {
1096                return Err(ProviderStoreError::Encoding);
1097            }
1098            output.extend_from_slice(number.to_string().as_bytes());
1099        }
1100        Value::String(value) => write_jcs_string(value, output)?,
1101        Value::Array(values) => {
1102            output.push(b'[');
1103            for (index, value) in values.iter().enumerate() {
1104                if index > 0 {
1105                    output.push(b',');
1106                }
1107                write_jcs(value, output)?;
1108            }
1109            output.push(b']');
1110        }
1111        Value::Object(values) => {
1112            let mut entries = values.iter().collect::<Vec<_>>();
1113            entries.sort_by(|(left, _), (right, _)| left.encode_utf16().cmp(right.encode_utf16()));
1114            output.push(b'{');
1115            for (index, (key, value)) in entries.into_iter().enumerate() {
1116                if index > 0 {
1117                    output.push(b',');
1118                }
1119                write_jcs_string(key, output)?;
1120                output.push(b':');
1121                write_jcs(value, output)?;
1122            }
1123            output.push(b'}');
1124        }
1125    }
1126    Ok(())
1127}
1128
1129fn write_jcs_string(value: &str, output: &mut Vec<u8>) -> Result<(), ProviderStoreError> {
1130    let encoded =
1131        Zeroizing::new(serde_json::to_string(value).map_err(|_| ProviderStoreError::Encoding)?);
1132    output.extend_from_slice(encoded.as_bytes());
1133    Ok(())
1134}
1135
1136fn decode_state(bytes: &[u8]) -> Result<StoreState, ProviderStoreError> {
1137    let strict: StrictValue =
1138        serde_json::from_slice(bytes).map_err(|_| ProviderStoreError::InvalidStore)?;
1139    let object = strict
1140        .0
1141        .as_object()
1142        .ok_or(ProviderStoreError::InvalidStore)?;
1143    match object.get("schema_version") {
1144        None => return Err(ProviderStoreError::UnversionedStore),
1145        Some(Value::Number(number))
1146            if number.as_u64().is_some_and(|version| {
1147                version > 0 && version < u64::from(PROVIDER_STORE_SCHEMA_VERSION)
1148            }) =>
1149        {
1150            return Err(ProviderStoreError::LegacyStoreVersion);
1151        }
1152        Some(Value::Number(number))
1153            if number.as_u64() == Some(u64::from(PROVIDER_STORE_SCHEMA_VERSION)) => {}
1154        Some(_) => return Err(ProviderStoreError::UnsupportedStoreVersion),
1155    }
1156    let disk: DiskState = serde_json::from_value(strict.into_value())
1157        .map_err(|_| ProviderStoreError::InvalidStore)?;
1158    let state = StoreState::try_from(disk)?;
1159    state.validate()?;
1160    Ok(state)
1161}
1162
1163struct StrictValue(Value);
1164
1165impl StrictValue {
1166    fn into_value(mut self) -> Value {
1167        std::mem::take(&mut self.0)
1168    }
1169}
1170
1171impl Drop for StrictValue {
1172    fn drop(&mut self) {
1173        zeroize_json_value(&mut self.0);
1174    }
1175}
1176
1177fn zeroize_json_value(value: &mut Value) {
1178    match value {
1179        Value::String(value) => value.zeroize(),
1180        Value::Array(values) => {
1181            for value in values {
1182                zeroize_json_value(value);
1183            }
1184        }
1185        Value::Object(values) => {
1186            for (mut key, mut value) in std::mem::take(values) {
1187                key.zeroize();
1188                zeroize_json_value(&mut value);
1189            }
1190        }
1191        Value::Null | Value::Bool(_) | Value::Number(_) => {}
1192    }
1193}
1194
1195impl<'de> Deserialize<'de> for StrictValue {
1196    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1197    where
1198        D: serde::Deserializer<'de>,
1199    {
1200        struct Visitor;
1201        impl<'de> serde::de::Visitor<'de> for Visitor {
1202            type Value = Value;
1203
1204            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1205                formatter.write_str("strict JSON")
1206            }
1207
1208            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
1209                Ok(Value::Bool(value))
1210            }
1211
1212            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1213                Ok(Value::Number(value.into()))
1214            }
1215
1216            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1217                Ok(Value::Number(value.into()))
1218            }
1219
1220            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1221            where
1222                E: serde::de::Error,
1223            {
1224                serde_json::Number::from_f64(value)
1225                    .map(Value::Number)
1226                    .ok_or_else(|| E::custom("invalid JSON number"))
1227            }
1228
1229            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
1230                Ok(Value::String(value.to_owned()))
1231            }
1232
1233            fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
1234                Ok(Value::String(value))
1235            }
1236
1237            fn visit_none<E>(self) -> Result<Self::Value, E> {
1238                Ok(Value::Null)
1239            }
1240
1241            fn visit_unit<E>(self) -> Result<Self::Value, E> {
1242                Ok(Value::Null)
1243            }
1244
1245            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1246            where
1247                A: serde::de::SeqAccess<'de>,
1248            {
1249                let mut values = Vec::new();
1250                while let Some(value) = sequence.next_element::<StrictValue>()? {
1251                    values.push(value.into_value());
1252                }
1253                Ok(Value::Array(values))
1254            }
1255
1256            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1257            where
1258                A: serde::de::MapAccess<'de>,
1259            {
1260                let mut values = serde_json::Map::new();
1261                while let Some((key, value)) = map.next_entry::<String, StrictValue>()? {
1262                    if values.insert(key, value.into_value()).is_some() {
1263                        return Err(serde::de::Error::custom("duplicate JSON key"));
1264                    }
1265                }
1266                Ok(Value::Object(values))
1267            }
1268        }
1269        deserializer.deserialize_any(Visitor).map(Self)
1270    }
1271}
1272
1273impl From<&StoreState> for DiskState {
1274    fn from(state: &StoreState) -> Self {
1275        Self {
1276            schema_version: PROVIDER_STORE_SCHEMA_VERSION,
1277            generation: state.generation,
1278            store_revision: state.store_revision.clone(),
1279            providers: state
1280                .providers
1281                .iter()
1282                .map(|(id, connection)| (id.clone(), DiskConnection::from(connection)))
1283                .collect(),
1284            connect_receipts: state
1285                .connect_receipts
1286                .iter()
1287                .map(|(id, receipt)| (id.clone(), DiskConnectReceipt::from(receipt)))
1288                .collect(),
1289            disconnect_receipts: state
1290                .disconnect_receipts
1291                .iter()
1292                .map(|(id, receipt)| (id.clone(), DiskDisconnectReceipt::from(receipt)))
1293                .collect(),
1294        }
1295    }
1296}
1297
1298impl TryFrom<DiskState> for StoreState {
1299    type Error = ProviderStoreError;
1300
1301    fn try_from(state: DiskState) -> Result<Self, Self::Error> {
1302        if state.schema_version != PROVIDER_STORE_SCHEMA_VERSION {
1303            return Err(ProviderStoreError::UnsupportedStoreVersion);
1304        }
1305        Ok(Self {
1306            generation: state.generation,
1307            store_revision: state.store_revision,
1308            providers: state
1309                .providers
1310                .into_iter()
1311                .map(|(id, connection)| Ok((id, StoredManagedConnection::try_from(connection)?)))
1312                .collect::<Result<_, ProviderStoreError>>()?,
1313            connect_receipts: state
1314                .connect_receipts
1315                .into_iter()
1316                .map(|(id, receipt)| Ok((id, StoredConnectReceipt::try_from(receipt)?)))
1317                .collect::<Result<_, ProviderStoreError>>()?,
1318            disconnect_receipts: state
1319                .disconnect_receipts
1320                .into_iter()
1321                .map(|(id, receipt)| Ok((id, StoredDisconnectReceipt::from(receipt))))
1322                .collect::<Result<_, ProviderStoreError>>()?,
1323        })
1324    }
1325}
1326
1327impl From<&StoredManagedConnection> for DiskConnection {
1328    fn from(connection: &StoredManagedConnection) -> Self {
1329        Self {
1330            provider_id: connection.provider_id.clone(),
1331            setup_values: disk_setup_values(&connection.setup_values),
1332            setup_fingerprint: connection.setup_fingerprint.clone(),
1333            auth_method: connection.auth_method.clone(),
1334            auth_values: connection
1335                .auth_values
1336                .0
1337                .iter()
1338                .map(|(field, value)| (field.clone(), DiskSecret::from(value)))
1339                .collect(),
1340            connection_generation: connection.connection_generation,
1341            policy: connection.policy.clone(),
1342            connected_at: connection.connected_at,
1343        }
1344    }
1345}
1346
1347impl TryFrom<DiskConnection> for StoredManagedConnection {
1348    type Error = ProviderStoreError;
1349
1350    fn try_from(connection: DiskConnection) -> Result<Self, Self::Error> {
1351        if connection.auth_values.len() > super::types::MAX_AUTH_FIELDS {
1352            return Err(ProviderStoreError::InvalidStore);
1353        }
1354        Ok(Self {
1355            provider_id: connection.provider_id,
1356            setup_values: live_setup_values(connection.setup_values),
1357            setup_fingerprint: connection.setup_fingerprint,
1358            auth_method: connection.auth_method,
1359            auth_values: ProviderAuthValues(
1360                connection
1361                    .auth_values
1362                    .into_iter()
1363                    .map(|(field, value)| Ok((field, value.into_secret()?)))
1364                    .collect::<Result<_, ProviderStoreError>>()?,
1365            ),
1366            connection_generation: connection.connection_generation,
1367            policy: connection.policy,
1368            connected_at: connection.connected_at,
1369        })
1370    }
1371}
1372
1373impl From<&StoredConnectReceipt> for DiskConnectReceipt {
1374    fn from(receipt: &StoredConnectReceipt) -> Self {
1375        Self {
1376            payload_digest: receipt.payload_digest.clone(),
1377            result: DiskConnectResult {
1378                durable_receipt: DiskDurableReceipt::from(&receipt.result.durable_receipt),
1379                durable_connection: DiskConnectionDescriptor::from(
1380                    &receipt.result.durable_connection,
1381                ),
1382            },
1383        }
1384    }
1385}
1386
1387impl TryFrom<DiskConnectReceipt> for StoredConnectReceipt {
1388    type Error = ProviderStoreError;
1389
1390    fn try_from(receipt: DiskConnectReceipt) -> Result<Self, Self::Error> {
1391        Ok(Self {
1392            payload_digest: receipt.payload_digest,
1393            result: ConnectReceipt {
1394                durable_receipt: DurableProviderReceipt::from(receipt.result.durable_receipt),
1395                durable_connection: super::types::DurableConnectionDescriptor::from(
1396                    receipt.result.durable_connection,
1397                ),
1398            },
1399        })
1400    }
1401}
1402
1403impl From<&StoredDisconnectReceipt> for DiskDisconnectReceipt {
1404    fn from(receipt: &StoredDisconnectReceipt) -> Self {
1405        Self {
1406            payload_digest: receipt.payload_digest.clone(),
1407            result: DiskDisconnectResult {
1408                durable_receipt: DiskDurableReceipt::from(&receipt.result.durable_receipt),
1409                provider_id: receipt.result.provider_id.clone(),
1410                disconnected: receipt.result.disconnected,
1411            },
1412        }
1413    }
1414}
1415
1416impl From<DiskDisconnectReceipt> for StoredDisconnectReceipt {
1417    fn from(receipt: DiskDisconnectReceipt) -> Self {
1418        Self {
1419            payload_digest: receipt.payload_digest,
1420            result: DisconnectReceipt {
1421                durable_receipt: DurableProviderReceipt::from(receipt.result.durable_receipt),
1422                provider_id: receipt.result.provider_id,
1423                disconnected: receipt.result.disconnected,
1424            },
1425        }
1426    }
1427}
1428
1429impl From<&DurableProviderReceipt> for DiskDurableReceipt {
1430    fn from(receipt: &DurableProviderReceipt) -> Self {
1431        Self {
1432            receipt_id: receipt.receipt_id,
1433            store_revision: receipt.store_revision.clone(),
1434            provider_state_revision: receipt.provider_state_revision.clone(),
1435            committed_at: receipt.committed_at,
1436        }
1437    }
1438}
1439
1440impl From<DiskDurableReceipt> for DurableProviderReceipt {
1441    fn from(receipt: DiskDurableReceipt) -> Self {
1442        Self {
1443            receipt_id: receipt.receipt_id,
1444            store_revision: receipt.store_revision,
1445            provider_state_revision: receipt.provider_state_revision,
1446            committed_at: receipt.committed_at,
1447        }
1448    }
1449}
1450
1451impl From<&super::types::DurableConnectionDescriptor> for DiskConnectionDescriptor {
1452    fn from(connection: &super::types::DurableConnectionDescriptor) -> Self {
1453        Self {
1454            provider_id: connection.provider_id.clone(),
1455            setup_values: disk_setup_values(&connection.setup_values),
1456            setup_fingerprint: connection.setup_fingerprint.clone(),
1457            recipe_fingerprint: connection.recipe_fingerprint.clone(),
1458            auth_method: connection.auth_method.clone(),
1459            credential_fields: connection.credential_fields.clone(),
1460            connection_generation: connection.connection_generation,
1461            connected_at: connection.connected_at,
1462        }
1463    }
1464}
1465
1466impl From<DiskConnectionDescriptor> for super::types::DurableConnectionDescriptor {
1467    fn from(connection: DiskConnectionDescriptor) -> Self {
1468        Self {
1469            provider_id: connection.provider_id,
1470            setup_values: live_setup_values(connection.setup_values),
1471            setup_fingerprint: connection.setup_fingerprint,
1472            recipe_fingerprint: connection.recipe_fingerprint,
1473            auth_method: connection.auth_method,
1474            credential_fields: connection.credential_fields,
1475            connection_generation: connection.connection_generation,
1476            connected_at: connection.connected_at,
1477        }
1478    }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483    use std::sync::atomic::Ordering;
1484
1485    use super::*;
1486
1487    #[test]
1488    fn reload_if_changed_fast_path_skips_the_transaction_when_unchanged() {
1489        let temporary = tempfile::tempdir().expect("tempdir");
1490        let store = ProviderStore::open(temporary.path().join("providers")).expect("store");
1491        let unknown = ProviderStoreGeneration::new(999_999).expect("generation");
1492        // First read opens a transaction and caches the content stamp.
1493        let snapshot = store
1494            .reload_if_changed(unknown)
1495            .expect("first reload")
1496            .expect("fresh generation differs from the known one");
1497        assert_eq!(store.transaction_opens.load(Ordering::Relaxed), 1);
1498        // Unchanged file plus known generation: no lock, no decode.
1499        assert!(
1500            store
1501                .reload_if_changed(snapshot.generation())
1502                .expect("second reload")
1503                .is_none()
1504        );
1505        assert_eq!(
1506            store.transaction_opens.load(Ordering::Relaxed),
1507            1,
1508            "unchanged store must skip the transaction"
1509        );
1510    }
1511
1512    #[test]
1513    fn reload_if_changed_reopens_after_external_modification() {
1514        let temporary = tempfile::tempdir().expect("tempdir");
1515        let store = ProviderStore::open(temporary.path().join("providers")).expect("store");
1516        let unknown = ProviderStoreGeneration::new(999_999).expect("generation");
1517        let snapshot = store
1518            .reload_if_changed(unknown)
1519            .expect("first reload")
1520            .expect("fresh generation differs from the known one");
1521        assert_eq!(store.transaction_opens.load(Ordering::Relaxed), 1);
1522        // An external write defeats the cached stamp, forcing the locked path
1523        // (which then rejects the garbage content).
1524        std::fs::write(
1525            temporary.path().join("providers").join(PROVIDER_STORE_FILE),
1526            b"external write",
1527        )
1528        .expect("external write");
1529        assert!(store.reload_if_changed(snapshot.generation()).is_err());
1530        assert_eq!(
1531            store.transaction_opens.load(Ordering::Relaxed),
1532            2,
1533            "modified store must reopen the transaction"
1534        );
1535    }
1536}