sambrs/
lib.rs

1#![warn(clippy::pedantic)]
2
3//! A tiny ergonomic wrapper around `WNetAddConnection2A` and `WNetCancelConnection2A`. The goal is
4//! to offer an easy to use interface to connect to SMB network shares on Windows.
5//!
6//! Sam -> SMB -> Rust -> Samba is taken!? -> sambrs
7//!
8//! # How To
9//!
10//! Instantiate an `SmbShare` with an optional local Windows mount point and establish a
11//! connection.
12//!
13//! When calling the connect method, you have the option to persist the connection across user
14//! login sessions and to enable interactive mode. Interactive mode will block until the user
15//! either provides a correct password or cancels, resulting in a `Canceled` error.
16//!
17//! ```no_run
18//! use sambrs::SmbShare;
19//!
20//! let share = SmbShare::new(r"\\server\share", "user", "pass", Some('D'));
21//!
22//! match share.connect(false, false) {
23//!     Ok(()) => println!("Connected successfully!"),
24//!     Err(e) => eprintln!("Failed to connect: {}", e),
25//! }
26//!
27//! // use std::fs as if D:\ was a local directory
28//! dbg!(std::fs::metadata(r"D:\").unwrap().is_dir());
29//! ```
30
31mod error;
32
33pub use error::{Error, Result};
34use std::ffi::CString;
35use tracing::{debug, error, trace};
36use windows_sys::Win32::Foundation::{
37    ERROR_ACCESS_DENIED, ERROR_ALREADY_ASSIGNED, ERROR_BAD_DEVICE, ERROR_BAD_DEV_TYPE,
38    ERROR_BAD_NET_NAME, ERROR_BAD_PROFILE, ERROR_BAD_PROVIDER, ERROR_BAD_USERNAME, ERROR_BUSY,
39    ERROR_CANCELLED, ERROR_CANNOT_OPEN_PROFILE, ERROR_DEVICE_ALREADY_REMEMBERED,
40    ERROR_DEVICE_IN_USE, ERROR_EXTENDED_ERROR, ERROR_INVALID_ADDRESS, ERROR_INVALID_PARAMETER,
41    ERROR_INVALID_PASSWORD, ERROR_LOGON_FAILURE, ERROR_NOT_CONNECTED, ERROR_NO_NETWORK,
42    ERROR_NO_NET_OR_BAD_PATH, ERROR_OPEN_FILES, FALSE, NO_ERROR, TRUE,
43};
44use windows_sys::Win32::NetworkManagement::WNet;
45
46pub struct SmbShare {
47    share: String,
48    username: String,
49    password: String,
50    mount_on: Option<char>,
51}
52
53impl SmbShare {
54    /// Create an `SmbShare` representation to connect to.
55    ///
56    /// Optionally specify `mount_on` to map the SMB share to a local device. Otherwise it will be
57    /// a deviceless connection. Case insensitive.
58    ///
59    /// # Example
60    ///
61    /// ```no_run
62    /// let share = sambrs::SmbShare::new(r"\\server.local\share", r"LOGONDOMAIN\user", "pass", None);
63    /// ```
64    pub fn new(
65        share: impl Into<String>,
66        username: impl Into<String>,
67        password: impl Into<String>,
68        mount_on: Option<char>,
69    ) -> Self {
70        Self {
71            share: share.into(),
72            username: username.into(),
73            password: password.into(),
74            mount_on,
75        }
76    }
77
78    /// Connect to the SMB share. Connecting multiple times works fine in deviceless mode but fails
79    /// with a local mount point.
80    ///
81    /// - `persist` will remember the connection and restore when the user logs off and on again. No-op
82    ///   if `mount_on` is `None`
83    /// - `interactive` will prompt the user for a password instead of failing with `Error::InvalidPassword`
84    ///
85    /// # Some excerpts from the [Microsoft docs](https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetaddconnection2a)
86    ///
87    /// `persist` (`CONNECT_UPDATE_PROFILE`): The network resource connection should be remembered. If this bit
88    /// flag is set, the operating system automatically attempts to restore the connection when the
89    /// user logs on.
90    ///
91    /// The operating system remembers only successful connections that redirect local devices. It does
92    /// not remember connections that are unsuccessful or deviceless connections. (A deviceless
93    /// connection occurs when the `lpLocalName` member is NULL or points to an empty string.)
94    ///
95    /// If this bit flag is clear, the operating system does not try to restore the connection when the
96    /// user logs on.
97    ///
98    /// `!persist` (`CONNECT_TEMPORARY`): The network resource connection should not be remembered. If this flag is
99    /// set, the operating system will not attempt to restore the connection when the user logs on
100    /// again.
101    ///
102    /// `interactive` (`CONNECT_INTERACTIVE`): If this flag is set, the operating system may interact with the user for
103    /// authentication purposes.
104    ///
105    /// # Errors
106    /// This method will error if Windows is unable to connect to the SMB share.
107    pub fn connect(&self, persist: bool, interactive: bool) -> Result<()> {
108        let local_name = self
109            .mount_on
110            .map(|ln| format!("{ln}:"))
111            .map(CString::new)
112            .transpose()?;
113
114        let local_name = match local_name {
115            Some(ref cstring) => cstring.as_c_str().as_ptr() as *mut u8,
116            None => std::ptr::null_mut(),
117        };
118
119        let mut flags = 0u32;
120
121        if persist && self.mount_on.is_some() {
122            flags |= WNet::CONNECT_UPDATE_PROFILE;
123        } else {
124            flags |= WNet::CONNECT_TEMPORARY;
125        }
126
127        if interactive {
128            flags |= WNet::CONNECT_INTERACTIVE;
129        }
130
131        debug!("Connection flags: {flags:#?}");
132
133        let share = CString::new(&*self.share)?;
134        let username = CString::new(&*self.username)?;
135        let password = CString::new(&*self.password)?;
136
137        // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/ns-winnetwk-netresourcea
138        let mut netresource = WNet::NETRESOURCEA {
139            dwDisplayType: 0, // ignored by WNetAddConnection2A
140            dwScope: 0,       // ignored by WNetAddConnection2A
141            dwType: WNet::RESOURCETYPE_DISK,
142            dwUsage: 0, // ignored by WNetAddConnection2A
143            lpLocalName: local_name,
144            lpRemoteName: share.as_c_str().as_ptr() as *mut u8,
145            lpComment: std::ptr::null_mut(), // ignored by WNetAddConnection2A
146            lpProvider: std::ptr::null_mut(), // Microsoft docs: You should set this member only if you know the network provider you want to use.
147                                              // Otherwise, let the operating system determine which provider the network name maps to.
148        };
149
150        trace!("Trying to connect to {}", self.share);
151
152        // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetaddconnection2a
153        let connection_result = unsafe {
154            let username = username.as_ref().as_ptr();
155            let password = password.as_ref().as_ptr();
156            WNet::WNetAddConnection2A(
157                std::ptr::from_mut::<WNet::NETRESOURCEA>(&mut netresource),
158                password.cast::<u8>(),
159                username.cast::<u8>(),
160                flags,
161            )
162        };
163
164        debug!("Connection result: {connection_result:#?}");
165
166        let connection_result = match connection_result {
167            NO_ERROR => Ok(()),
168            ERROR_ACCESS_DENIED => Err(Error::AccessDenied),
169            ERROR_ALREADY_ASSIGNED => Err(Error::AlreadyAssigned),
170            ERROR_BAD_DEV_TYPE => Err(Error::BadDevType),
171            ERROR_BAD_DEVICE => Err(Error::BadDevice),
172            ERROR_BAD_NET_NAME => Err(Error::BadNetName),
173            ERROR_BAD_PROFILE => Err(Error::BadProfile),
174            ERROR_BAD_PROVIDER => Err(Error::BadProvider),
175            ERROR_BAD_USERNAME => Err(Error::BadUsername),
176            ERROR_BUSY => Err(Error::Busy),
177            ERROR_CANCELLED => Err(Error::Cancelled),
178            ERROR_CANNOT_OPEN_PROFILE => Err(Error::CannotOpenProfile),
179            ERROR_DEVICE_ALREADY_REMEMBERED => Err(Error::DeviceAlreadyRemembered),
180            ERROR_EXTENDED_ERROR => Err(Error::ExtendedError),
181            ERROR_INVALID_ADDRESS => Err(Error::InvalidAddress),
182            ERROR_INVALID_PARAMETER => Err(Error::InvalidParameter),
183            ERROR_INVALID_PASSWORD => Err(Error::InvalidPassword),
184            ERROR_LOGON_FAILURE => Err(Error::LogonFailure),
185            ERROR_NO_NET_OR_BAD_PATH => Err(Error::NoNetOrBadPath),
186            ERROR_NO_NETWORK => Err(Error::NoNetwork),
187            _ => Err(Error::Other),
188        };
189
190        match connection_result {
191            Ok(()) => {
192                trace!("Successfully connected");
193            }
194            Err(ref e) => error!("Connection failed: {e}"),
195        };
196
197        connection_result
198    }
199
200    /// Disconnect from the SMB share.
201    ///
202    /// `persist` (`CONNECT_UPDATE_PROFILE`): The system updates the user profile with the
203    /// information that the connection is no longer a persistent one. The system will not restore
204    /// this connection during subsequent logon operations. (Disconnecting resources using remote
205    /// names has no effect on persistent connections.)
206    ///
207    /// `force`: Specifies whether the disconnection should occur if there are open files or jobs
208    /// on the connection. If this parameter is FALSE, the function fails if there are open files
209    /// or jobs.
210    ///
211    /// # Errors
212    /// This method will return an error if Windows is unable to disconnect from the smb share.
213    pub fn disconnect(&self, persist: bool, force: bool) -> Result<()> {
214        let local_name = self
215            .mount_on
216            .map(|ln| format!("{ln}:"))
217            .map(CString::new)
218            .transpose()?;
219
220        let resource_to_disconnect = local_name.unwrap_or(CString::new(&*self.share)?);
221
222        let force = if force { TRUE } else { FALSE };
223
224        let persist = if persist && self.mount_on.is_some() {
225            WNet::CONNECT_UPDATE_PROFILE
226        } else {
227            0
228        };
229
230        let disconnect_result = unsafe {
231            WNet::WNetCancelConnection2A(resource_to_disconnect.as_ptr() as *mut u8, persist, force)
232        };
233
234        debug!("Disconnect result: {disconnect_result:#?}");
235
236        let disconnect_result = match disconnect_result {
237            NO_ERROR => Ok(()),
238            ERROR_BAD_PROFILE => Err(Error::BadProfile),
239            ERROR_CANNOT_OPEN_PROFILE => Err(Error::CannotOpenProfile),
240            ERROR_DEVICE_IN_USE => Err(Error::DeviceInUse),
241            ERROR_EXTENDED_ERROR => Err(Error::ExtendedError),
242            ERROR_NOT_CONNECTED => Err(Error::NotConnected),
243            ERROR_OPEN_FILES => Err(Error::OpenFiles),
244            _ => Err(Error::Other),
245        };
246
247        match disconnect_result {
248            Ok(()) => trace!("Successfully disconnected"),
249            Err(ref e) => error!("Disconnect failed: {e}"),
250        }
251
252        disconnect_result
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    // TODO: propper integration test setup
261
262    const VALID_SHARE: &str = r"PUTYOURSHARE";
263    const CORRECT_USERNAME: &str = r"PUTYOURUSER";
264    const CORRECT_PASSWORD: &str = r"PUTYOURPASS";
265
266    const WRONG_SHARE: &str = r"\\thisisnotashare.local\Share-Name";
267    const WRONG_PASSWORD: &str = r"pass";
268
269    // I really wanted to assert against a specific error, but lovely Windows sometimes returns
270    // `LogonFailure` and sometimes `InvalidPassword`.
271    #[test]
272    fn sad_non_interactive_does_not_prompt_and_returns_error() {
273        let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, WRONG_PASSWORD, None);
274        let connection = share.connect(false, false);
275        assert!(connection.is_err());
276        if let Err(e) = connection {
277            assert!(e == Error::InvalidPassword || e == Error::LogonFailure);
278        }
279    }
280
281    #[test]
282    fn sad_non_existent_share() {
283        let share = SmbShare::new(WRONG_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, None);
284        let connection = share.connect(false, false);
285        assert!(connection.is_err());
286        if let Err(e) = connection {
287            assert_eq!(e, Error::BadNetName);
288        }
289    }
290
291    #[test]
292    fn happy_mount_on_works_and_does_not_persist() {
293        let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('s'));
294        let connection = share.connect(false, false);
295        assert!(connection.is_ok());
296        assert!(std::path::Path::new(r"s:\").is_dir());
297        let disconnect = share.disconnect(false, false);
298        assert!(disconnect.is_ok());
299    }
300
301    #[test]
302    fn happy_deviceless_works() {
303        let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, None);
304        let connection = share.connect(false, false);
305        assert!(connection.is_ok());
306        assert!(std::path::Path::new(VALID_SHARE).is_dir());
307        let disconnect = share.disconnect(false, false);
308        assert!(disconnect.is_ok());
309    }
310
311    #[test]
312    fn happy_deviceless_reconnecting_is_fine() {
313        let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, None);
314        let connection = share.connect(false, false);
315        assert!(connection.is_ok());
316        let connection = share.connect(false, false);
317        assert!(connection.is_ok());
318        assert!(std::path::Path::new(VALID_SHARE).is_dir());
319        let disconnect = share.disconnect(false, false);
320        assert!(disconnect.is_ok());
321    }
322
323    #[test]
324    fn sad_mounted_reconnecting_returns_already_assigned_error() {
325        let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('s'));
326        let connection = share.connect(false, false);
327        assert!(connection.is_ok());
328        assert!(std::path::Path::new(r"s:\").is_dir());
329        let connection = share.connect(false, false);
330        assert!(connection.is_err());
331        if let Err(e) = connection {
332            assert_eq!(e, Error::AlreadyAssigned);
333        }
334        let disconnect = share.disconnect(false, false);
335        assert!(disconnect.is_ok());
336    }
337
338    #[test]
339    fn happy_connecting_multiple_letters_to_same_share_works() {
340        let share_one = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('s'));
341        let connection1 = share_one.connect(false, false);
342        assert!(connection1.is_ok());
343        let share_two = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('t'));
344        let connection2 = share_two.connect(false, false);
345        assert!(connection2.is_ok());
346        assert!(std::path::Path::new(r"s:\").is_dir());
347        assert!(std::path::Path::new(r"t:\").is_dir());
348        let share_one_disconnect = share_one.disconnect(false, false);
349        assert!(share_one_disconnect.is_ok());
350        assert!(!std::path::Path::new(r"s:\").is_dir());
351        let share_two_disconnect = share_two.disconnect(false, false);
352        assert!(share_two_disconnect.is_ok());
353        assert!(!std::path::Path::new(r"t:\").is_dir());
354    }
355}