Skip to main content

smb_server_handle_store/
lib.rs

1//! Pluggable store for durable and persistent SMB handles ([MS-SMB2] §3.3.1.10).
2//!
3//! A durable/persistent handle survives a client disconnect (and, for the
4//! replicated backends, a server node failing over) so the client can reclaim
5//! it with `DH2C`. This crate abstracts *where* that handle state lives behind
6//! one async [`HandleStore`] trait so the server code is backend-agnostic:
7//!
8//! - [`MemStore`] — in-process, non-durable (single node, dev/tests).
9//! - `RedbStore` (feature `redb-backend`) — embedded durable KV; survives a
10//!   process restart on one node.
11//! - Replicated backends (openraft / etcd) plug in behind the same trait for
12//!   multi-node continuous availability without touching the SMB code.
13//!
14//! Only handle *lifecycle* state lives here (open/close/reclaim/lock), never
15//! the file data path — values stay small and writes are infrequent.
16
17use std::collections::HashMap;
18use std::sync::Mutex;
19
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22
23#[cfg(feature = "redb-backend")]
24mod redb_store;
25#[cfg(feature = "redb-backend")]
26pub use redb_store::RedbStore;
27
28/// SMB create GUID identifying a durable handle ([MS-SMB2] §2.2.13.2.3).
29pub type Guid = [u8; 16];
30
31/// Persisted state for one durable/persistent handle.
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33pub struct HandleRecord {
34    /// Server-assigned create GUID that keys this handle ([MS-SMB2] §2.2.13.2.3).
35    pub create_guid: Guid,
36    /// Share-relative path of the open, used to re-open on reclaim.
37    pub path: String,
38    /// Share name the handle belongs to.
39    pub share: String,
40    /// Session that owns the open, validated on reclaim.
41    pub session_id: u64,
42    /// User (SecurityContext) that owns the durable handle; a reconnect by a
43    /// different user is rejected with STATUS_ACCESS_DENIED ([MS-SMB2] §3.3.5.9.7).
44    pub owner_user: String,
45    /// Granted access mask re-applied when the handle is re-opened.
46    pub access: u32,
47    /// Share access (`FILE_SHARE_*`) the open was granted.
48    pub share_access: u32,
49    /// CreateOptions the open was made with, re-applied on reclaim.
50    pub create_options: u32,
51    /// Directory handle (re-opened as a directory on reclaim).
52    pub is_dir: bool,
53    /// Durable flags (v2 request / persistent).
54    pub flags: u32,
55    /// Client create-guid to validate on a v2 reconnect (`None` for v1).
56    pub match_guid: Option<Guid>,
57    /// Node currently owning the handle; empty when free for reclaim.
58    pub owner_node: String,
59    /// Lease key bound to the handle when it was opened with a lease.
60    pub lease_key: Option<Guid>,
61    /// Granted lease state, recreated in the CREATE response on a persistent
62    /// resume ([MS-SMB2] §3.3.5.9.12).
63    pub lease_state: u32,
64    /// Client GUID that opened the handle; validated on a lease reconnect
65    /// ([MS-SMB2] §3.3.5.9.7).
66    pub client_guid: Guid,
67    /// Requested durable timeout in milliseconds (0 = persistent, no timeout).
68    pub timeout_ms: u64,
69    /// Absolute expiry (ms since epoch); 0 means persistent / never expires.
70    pub deadline_ms: u64,
71    /// Whether the open is marked delete-on-close.
72    pub delete_on_close: bool,
73}
74
75impl HandleRecord {
76    /// A persistent handle has no timeout and is never swept.
77    pub fn is_persistent(&self) -> bool {
78        self.deadline_ms == 0
79    }
80
81    fn expired_at(&self, now_ms: u64) -> bool {
82        !self.is_persistent() && self.deadline_ms <= now_ms
83    }
84}
85
86/// Errors surfaced by a [`HandleStore`] backend.
87#[derive(Debug, thiserror::Error)]
88pub enum StoreError {
89    /// The backend (KV store, network, filesystem) failed.
90    #[error("handle store backend error: {0}")]
91    Backend(String),
92    /// A record failed to serialize or deserialize.
93    #[error("handle store serialization error: {0}")]
94    Serde(String),
95}
96
97/// Backend-agnostic durable/persistent handle registry.
98///
99/// Implementations must make [`reclaim`](HandleStore::reclaim) atomic so two
100/// nodes cannot both win the same handle during failover.
101#[async_trait]
102pub trait HandleStore: Send + Sync {
103    /// Insert or replace a handle record (durable grant).
104    async fn put(&self, record: HandleRecord) -> Result<(), StoreError>;
105
106    /// Fetch a handle record by its create GUID.
107    async fn get(&self, create_guid: &Guid) -> Result<Option<HandleRecord>, StoreError>;
108
109    /// Atomically validate and remove a handle for a durable reconnect
110    /// ([MS-SMB2] §3.3.5.9.7): the record must exist, be unexpired, and — when
111    /// `match_guid` is `Some` (v2 reconnect) — carry the same client guid.
112    /// Returns the removed record on success, else `None`.
113    async fn take(
114        &self,
115        create_guid: &Guid,
116        match_guid: Option<Guid>,
117        now_ms: u64,
118    ) -> Result<Option<HandleRecord>, StoreError>;
119
120    /// Atomically claim a handle for `owner` when it is free, already owned by
121    /// `owner`, or expired. Returns the (now owned) record, or `None` if it is
122    /// still validly owned by another node. Reclaim renews the deadline.
123    async fn reclaim(
124        &self,
125        create_guid: &Guid,
126        owner: &str,
127        now_ms: u64,
128    ) -> Result<Option<HandleRecord>, StoreError>;
129
130    /// Remove a handle record (close or durable expiry).
131    async fn remove(&self, create_guid: &Guid) -> Result<(), StoreError>;
132
133    /// Drop every non-persistent handle whose deadline has passed; returns the
134    /// GUIDs removed so the caller can release their backing resources.
135    async fn sweep_expired(&self, now_ms: u64) -> Result<Vec<Guid>, StoreError>;
136
137    /// All live handle records (diagnostics / reconciliation).
138    async fn list(&self) -> Result<Vec<HandleRecord>, StoreError>;
139}
140
141/// In-process, non-durable store — the default single-node/dev backend.
142#[derive(Default)]
143pub struct MemStore {
144    map: Mutex<HashMap<Guid, HandleRecord>>,
145}
146
147impl MemStore {
148    /// Create an empty in-memory store.
149    pub fn new() -> MemStore {
150        MemStore { map: Mutex::new(HashMap::new()) }
151    }
152
153    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<Guid, HandleRecord>> {
154        self.map.lock().unwrap_or_else(|e| e.into_inner())
155    }
156}
157
158#[async_trait]
159impl HandleStore for MemStore {
160    async fn put(&self, record: HandleRecord) -> Result<(), StoreError> {
161        self.lock().insert(record.create_guid, record);
162        Ok(())
163    }
164
165    async fn get(&self, create_guid: &Guid) -> Result<Option<HandleRecord>, StoreError> {
166        Ok(self.lock().get(create_guid).cloned())
167    }
168
169    async fn take(
170        &self,
171        create_guid: &Guid,
172        match_guid: Option<Guid>,
173        now_ms: u64,
174    ) -> Result<Option<HandleRecord>, StoreError> {
175        let mut map = self.lock();
176        let Some(record) = map.get(create_guid) else {
177            return Ok(None);
178        };
179        if record.expired_at(now_ms) {
180            map.remove(create_guid);
181            return Ok(None);
182        }
183        if let Some(g) = match_guid
184            && record.match_guid != Some(g) {
185                return Ok(None);
186            }
187        Ok(map.remove(create_guid))
188    }
189
190    async fn reclaim(
191        &self,
192        create_guid: &Guid,
193        owner: &str,
194        now_ms: u64,
195    ) -> Result<Option<HandleRecord>, StoreError> {
196        let mut map = self.lock();
197        let Some(record) = map.get_mut(create_guid) else {
198            return Ok(None);
199        };
200        if !record.owner_node.is_empty() && record.owner_node != owner && !record.expired_at(now_ms) {
201            return Ok(None);
202        }
203        record.owner_node = owner.to_string();
204        if record.timeout_ms > 0 {
205            record.deadline_ms = now_ms + record.timeout_ms;
206        }
207        Ok(Some(record.clone()))
208    }
209
210    async fn remove(&self, create_guid: &Guid) -> Result<(), StoreError> {
211        self.lock().remove(create_guid);
212        Ok(())
213    }
214
215    async fn sweep_expired(&self, now_ms: u64) -> Result<Vec<Guid>, StoreError> {
216        let mut map = self.lock();
217        let expired: Vec<Guid> =
218            map.values().filter(|r| r.expired_at(now_ms)).map(|r| r.create_guid).collect();
219        for guid in &expired {
220            map.remove(guid);
221        }
222        Ok(expired)
223    }
224
225    async fn list(&self) -> Result<Vec<HandleRecord>, StoreError> {
226        Ok(self.lock().values().cloned().collect())
227    }
228}
229
230#[cfg(test)]
231pub(crate) fn sample(guid_byte: u8, timeout_ms: u64) -> HandleRecord {
232    HandleRecord {
233        create_guid: [guid_byte; 16],
234        path: "dir/file.bin".into(),
235        share: "public".into(),
236        session_id: 0x1234,
237        owner_user: "admin".into(),
238        access: 0x0012_019f,
239        share_access: 0x7,
240        create_options: 0x40,
241        is_dir: false,
242        flags: 0x2,
243        match_guid: None,
244        owner_node: String::new(),
245        lease_key: None,
246        lease_state: 0,
247        client_guid: [0u8; 16],
248        timeout_ms,
249        deadline_ms: if timeout_ms == 0 { 0 } else { timeout_ms },
250        delete_on_close: false,
251    }
252}
253
254#[cfg(test)]
255mod mem_tests {
256    use super::*;
257
258    #[tokio::test]
259    async fn put_get_remove_roundtrip() {
260        let store = MemStore::new();
261        let rec = sample(1, 1000);
262        store.put(rec.clone()).await.unwrap();
263        assert_eq!(store.get(&rec.create_guid).await.unwrap().as_ref(), Some(&rec));
264        store.remove(&rec.create_guid).await.unwrap();
265        assert!(store.get(&rec.create_guid).await.unwrap().is_none());
266    }
267
268    #[tokio::test]
269    async fn reclaim_is_exclusive_until_expiry() {
270        let store = MemStore::new();
271        store.put(sample(2, 1000)).await.unwrap();
272        let guid = [2u8; 16];
273        // Node A claims it.
274        assert!(store.reclaim(&guid, "nodeA", 0).await.unwrap().is_some());
275        // Node B cannot steal it while A's lease is valid.
276        assert!(store.reclaim(&guid, "nodeB", 500).await.unwrap().is_none());
277        // After the deadline, B reclaims it.
278        let claimed = store.reclaim(&guid, "nodeB", 2000).await.unwrap();
279        assert_eq!(claimed.unwrap().owner_node, "nodeB");
280    }
281
282    #[tokio::test]
283    async fn sweep_drops_expired_but_keeps_persistent() {
284        let store = MemStore::new();
285        store.put(sample(3, 1000)).await.unwrap(); // expires at 1000
286        store.put(sample(4, 0)).await.unwrap(); // persistent
287        let dropped = store.sweep_expired(2000).await.unwrap();
288        assert_eq!(dropped, vec![[3u8; 16]]);
289        assert!(store.get(&[3u8; 16]).await.unwrap().is_none());
290        assert!(store.get(&[4u8; 16]).await.unwrap().is_some());
291    }
292
293    #[tokio::test]
294    async fn take_validates_guid_and_removes() {
295        let store = MemStore::new();
296        let mut rec = sample(5, 1000);
297        rec.match_guid = Some([0xAB; 16]);
298        store.put(rec).await.unwrap();
299        let guid = [5u8; 16];
300        // Wrong client guid is rejected without removing the record.
301        assert!(store.take(&guid, Some([0x00; 16]), 0).await.unwrap().is_none());
302        assert!(store.get(&guid).await.unwrap().is_some());
303        // Correct guid takes (and removes) it.
304        assert!(store.take(&guid, Some([0xAB; 16]), 0).await.unwrap().is_some());
305        assert!(store.get(&guid).await.unwrap().is_none());
306    }
307}