diff --git a/crates/ironrdp-cfg/src/lib.rs b/crates/ironrdp-cfg/src/lib.rs index 215855627e..6687ae4add 100644 --- a/crates/ironrdp-cfg/src/lib.rs +++ b/crates/ironrdp-cfg/src/lib.rs @@ -315,6 +315,153 @@ impl core::fmt::Display for DvcPipeSpecMissingDelimiter { impl core::error::Error for DvcPipeSpecMissingDelimiter {} +/// Local drives selected for redirection. +/// +/// Corresponds to the `drivestoredirect` `.rdp` property, using the mstsc syntax: entries are +/// separated by `;`, `*` selects every drive (including drives connected later), `DynamicDrives` +/// selects drives connected later, and `C:\` (or `C:`, `C`) selects one drive letter. +/// Unrecognized entries, such as FreeRDP-specific paths, are ignored; see +/// [`Self::unrecognized_entries`]. +/// +/// Selecting every drive implies dynamic drives, because `*` cannot express one without the other. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DriveRedirection { + /// INVARIANT: only the low 26 bits are used; bit `n` selects the letter `'A' + n`. + letters: u32, + all: bool, + dynamic: bool, +} + +impl DriveRedirection { + const DYNAMIC_DRIVES: &str = "DynamicDrives"; + + /// Selects every local drive, including drives connected later (`*`). + pub fn all() -> Self { + Self { + letters: 0, + all: true, + dynamic: true, + } + } + + /// Parses a `drivestoredirect` value. + pub fn parse(value: &str) -> Self { + let mut selection = Self::default(); + for (_, entry) in Self::entries(value) { + match entry { + Some(DriveEntry::All) => { + selection.all = true; + selection.dynamic = true; + } + Some(DriveEntry::Dynamic) => selection.dynamic = true, + Some(DriveEntry::Letter(index)) => selection.letters |= 1 << index, + None => {} + } + } + if selection.all { + // `*` already selects every letter. + selection.letters = 0; + } + selection + } + + /// Returns the entries of a `drivestoredirect` value that [`Self::parse`] ignores, so callers + /// can report them. + pub fn unrecognized_entries(value: &str) -> impl Iterator { + Self::entries(value).filter_map(|(text, entry)| entry.is_none().then_some(text)) + } + + /// Splits a `drivestoredirect` value into its nonempty entries and their meaning. + fn entries(value: &str) -> impl Iterator)> { + value + .split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| { + let kind = if entry == "*" { + Some(DriveEntry::All) + } else if entry.eq_ignore_ascii_case(Self::DYNAMIC_DRIVES) { + Some(DriveEntry::Dynamic) + } else { + drive_letter_index(entry).map(DriveEntry::Letter) + }; + (entry, kind) + }) + } + + /// Returns this selection with drives connected later enabled or disabled. + /// + /// Has no effect on a selection of every drive. + #[must_use] + pub fn with_dynamic(mut self, enabled: bool) -> Self { + self.dynamic = enabled || self.all; + self + } + + /// Whether every local drive is selected. + pub fn redirects_all(&self) -> bool { + self.all + } + + /// Whether drives connected during the session are selected. + pub fn redirects_dynamic(&self) -> bool { + self.dynamic + } + + /// Returns the uppercase drive letters selected explicitly, in alphabetical order. + pub fn letters(&self) -> impl Iterator + '_ { + (b'A'..=b'Z') + .zip(0u32..) + .filter(|(_, index)| self.letters & (1 << index) != 0) + .map(|(letter, _)| char::from(letter)) + } + + /// Whether no drive is selected. + pub fn is_empty(&self) -> bool { + self.letters == 0 && !self.all && !self.dynamic + } +} + +impl core::fmt::Display for DriveRedirection { + /// Formats the selection as a `drivestoredirect` value. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if self.all { + return f.write_str("*"); + } + + let mut separator = ""; + for letter in self.letters() { + write!(f, "{separator}{letter}:\\")?; + separator = ";"; + } + if self.dynamic { + write!(f, "{separator}{}", Self::DYNAMIC_DRIVES)?; + } + Ok(()) + } +} + +/// A recognized `drivestoredirect` entry. +#[derive(Clone, Copy)] +enum DriveEntry { + /// `*` + All, + /// `DynamicDrives` + Dynamic, + /// A drive letter, as its bit index. + Letter(u32), +} + +/// Returns the bit index of a `C`, `C:`, `C:\` or `C:/` drive entry. +fn drive_letter_index(entry: &str) -> Option { + match entry.as_bytes() { + [letter] | [letter, b':'] | [letter, b':', b'\\' | b'/'] if letter.is_ascii_alphabetic() => { + Some(u32::from(letter.to_ascii_uppercase() - b'A')) + } + _ => None, + } +} + /// Typed accessors for the RDP properties IronRDP understands. /// /// Every property is exposed as a triplet of methods sharing the same underlying key: a getter @@ -411,6 +558,15 @@ pub trait PropertySetExt { /// Removes the `domain` property. fn clear_domain(&mut self); + /// Raw local drive selection (`drivestoredirect`); see [`DriveRedirection`] for the syntax. + /// + /// Prefer [`Self::drive_redirection`], which also honors the legacy `redirectdrives` key. + fn drives_to_redirect(&self) -> Option<&str>; + /// Sets the `drivestoredirect` property. + fn set_drives_to_redirect(&mut self, value: impl Into); + /// Removes the `drivestoredirect` property. + fn clear_drives_to_redirect(&mut self); + /// Whether CredSSP/NLA support is enabled (`enablecredsspsupport`). fn enable_credssp_support(&self) -> Option; /// Sets the `enablecredsspsupport` property. @@ -485,6 +641,15 @@ pub trait PropertySetExt { /// Removes the `redirectclipboard` property. fn clear_redirect_clipboard(&mut self); + /// Whether every local drive is redirected (`redirectdrives`, legacy). + /// + /// Prefer [`Self::drive_redirection`], which gives `drivestoredirect` precedence. + fn redirect_drives(&self) -> Option; + /// Sets the `redirectdrives` property. + fn set_redirect_drives(&mut self, value: bool); + /// Removes the `redirectdrives` property. + fn clear_redirect_drives(&mut self); + /// Whether WebAuthn redirection is requested (`redirectwebauthn`). fn redirect_webauthn(&self) -> Option; /// Sets the `redirectwebauthn` property. @@ -681,6 +846,16 @@ pub trait PropertySetExt { /// `gatewayusername`, and both `GatewayPassword` casings). fn clear_gateway(&mut self); + /// Local drive selection resolved from `drivestoredirect` and the legacy `redirectdrives`. + /// + /// An explicit `redirectdrives:i:0` disables drive redirection, so clearing the legacy flag + /// cannot leave a stale `drivestoredirect` list exposed. Otherwise a nonempty + /// `drivestoredirect` takes precedence, and `redirectdrives:i:1` alone selects every drive, as + /// in FreeRDP. Returns `None` when neither key is present. + fn drive_redirection(&self) -> Option; + /// Writes `drivestoredirect` and `redirectdrives` from a drive selection. + fn set_drive_redirection(&mut self, value: DriveRedirection); + /// Removes every RDCleanPath-related key (`ironrdp_rdcleanpathurl` and /// `ironrdp_rdcleanpathtoken`). fn clear_rdcleanpath(&mut self); @@ -831,6 +1006,18 @@ impl PropertySetExt for PropertySet { self.remove("domain"); } + fn drives_to_redirect(&self) -> Option<&str> { + self.get::<&str>("drivestoredirect") + } + + fn set_drives_to_redirect(&mut self, value: impl Into) { + self.insert("drivestoredirect", value.into()); + } + + fn clear_drives_to_redirect(&mut self) { + self.remove("drivestoredirect"); + } + fn enable_credssp_support(&self) -> Option { self.get::("enablecredsspsupport") } @@ -961,6 +1148,18 @@ impl PropertySetExt for PropertySet { self.remove("redirectclipboard"); } + fn redirect_drives(&self) -> Option { + self.get::("redirectdrives") + } + + fn set_redirect_drives(&mut self, value: bool) { + self.insert("redirectdrives", value); + } + + fn clear_redirect_drives(&mut self) { + self.remove("redirectdrives"); + } + fn redirect_webauthn(&self) -> Option { self.get::("redirectwebauthn") } @@ -1340,6 +1539,23 @@ impl PropertySetExt for PropertySet { self.remove("GatewayPassword"); } + fn drive_redirection(&self) -> Option { + let drives_to_redirect = self.drives_to_redirect(); + let redirect_drives = self.redirect_drives(); + match (drives_to_redirect.map(str::trim), redirect_drives) { + (_, Some(false)) => Some(DriveRedirection::default()), + (Some(value), _) if !value.is_empty() => Some(DriveRedirection::parse(value)), + (_, Some(true)) => Some(DriveRedirection::all()), + (None, None) => None, + _ => Some(DriveRedirection::default()), + } + } + + fn set_drive_redirection(&mut self, value: DriveRedirection) { + self.set_drives_to_redirect(value.to_string()); + self.set_redirect_drives(!value.is_empty()); + } + fn clear_rdcleanpath(&mut self) { self.remove("ironrdp_rdcleanpathurl"); self.remove("ironrdp_rdcleanpathtoken"); diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 900c849305..f260f7594c 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -11,6 +11,10 @@ use ironrdp_propertyset::PropertySet; use ironrdp_rail::pdu::ExecutePdu; use url::Url; +/// Local drive selection, as configured with [`ConfigBuilder::with_drive_redirection`]. +#[cfg(feature = "rdpdr")] +pub use ironrdp_cfg::DriveRedirection; + /// RDP-UDP protocol version, as offered with [`ConfigBuilder::with_udp_offer_version`]. #[cfg(feature = "udp")] pub use ironrdp_rdpeudp::pdu::UdpVersion; @@ -435,6 +439,12 @@ pub struct RdpdrConfig { /// Enable smart-card redirection within RDPDR. #[cfg(feature = "smartcard")] pub smartcard: bool, + + /// Local drives requested for redirection. + /// + /// The client does not enumerate drives itself: frontends read this selection to configure + /// their native RDPDR backend factory. + pub drives: DriveRedirection, } #[cfg(feature = "rdpdr")] @@ -444,6 +454,7 @@ impl Default for RdpdrConfig { enabled: true, #[cfg(feature = "smartcard")] smartcard: true, + drives: DriveRedirection::default(), } } } @@ -1484,6 +1495,15 @@ impl ConfigBuilder { self } + /// Selects the local drives to redirect (`drivestoredirect`). + #[cfg(feature = "rdpdr")] + #[must_use] + pub fn with_drive_redirection(mut self, drives: DriveRedirection) -> Self { + self.channels.rdpdr.drives = drives; + self.properties.set_drive_redirection(drives); + self + } + /// Enable or disable smart-card redirection within RDPDR. #[cfg(feature = "smartcard")] #[must_use] @@ -2337,6 +2357,10 @@ impl ConfigBuilder { if let Some(enabled) = ps.enable_rdpdr() { self.channels.rdpdr.enabled = enabled; } + #[cfg(feature = "rdpdr")] + if let Some(drives) = ps.drive_redirection() { + self.channels.rdpdr.drives = drives; + } #[cfg(feature = "smartcard")] if let Some(enabled) = ps.enable_smartcard() { self.channels.rdpdr.smartcard = enabled; diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index dba2042d17..4672b19a1b 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -3005,6 +3005,15 @@ fn process_rdpdr_drive_change( warn!(device_id, "Ignoring dynamic drive change because RDPDR is disabled"); return Ok(Vec::new()); }; + // A channel built without drive capability (smartcard-only, or drives forbidden by gateway + // policy) must not gain it from a drive event queued before server negotiation. + if name.is_some() && !rdpdr.drive_hotplug_available() { + warn!( + device_id, + "Ignoring dynamic drive because drive redirection is unavailable" + ); + return Ok(Vec::new()); + } let messages = match name { Some(name) => rdpdr.add_dynamic_drive(device_id, name), None => rdpdr.remove_drive(device_id), @@ -4916,6 +4925,7 @@ mod tests { enabled: false, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; assert!( @@ -4936,6 +4946,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; assert!( @@ -4956,6 +4967,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; let mut rdpdr = build_rdpdr_channel(Some(&factory), &config, RdpdrDevicePolicy::ALLOW_ALL) @@ -5025,6 +5037,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; assert!(build_rdpdr_channel(Some(&factory), &config, RdpdrDevicePolicy::ALLOW_ALL).is_err()); @@ -5038,6 +5051,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; let mut rdpdr = build_rdpdr_channel(Some(&factory), &config, RdpdrDevicePolicy::ALLOW_ALL) @@ -5077,6 +5091,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; let policy = RdpdrDevicePolicy { drives: true, @@ -5099,6 +5114,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; assert!( @@ -5116,6 +5132,7 @@ mod tests { let config = crate::config::RdpdrConfig { enabled: true, smartcard: true, + drives: Default::default(), }; let mut rdpdr = build_rdpdr_channel(Some(&factory), &config, DRIVES_RESTRICTED) @@ -5209,6 +5226,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; let first = build_rdpdr_channel(Some(&factory), &config, RdpdrDevicePolicy::ALLOW_ALL) @@ -5243,6 +5261,7 @@ mod tests { enabled: true, #[cfg(feature = "smartcard")] smartcard: false, + drives: Default::default(), }; let mut rdpdr = build_rdpdr_channel(Some(&factory), &config, RdpdrDevicePolicy::ALLOW_ALL) .expect("RDPDR channel should build") @@ -5404,6 +5423,43 @@ mod tests { ); } + #[cfg(feature = "rdpdr")] + #[test] + fn dynamic_drives_do_not_enable_drive_capability() { + let backend = TestRdpdrBackend::new(0); + let dynamic_drive_count = Arc::clone(&backend.dynamic_drive_count); + let mut static_channels = StaticChannelSet::new(); + assert!( + static_channels + .insert(ironrdp_rdpdr::Rdpdr::new(Box::new(backend), "test".to_owned())) + .is_none() + ); + let mut active_stage = ActiveStageBuilder { + static_channels, + user_channel_id: 1001, + io_channel_id: 1003, + message_channel_id: None, + share_id: 0, + compression_type: None, + enable_server_pointer: false, + pointer_software_rendering: false, + } + .build(); + + assert!( + process_rdpdr_drive_change(&mut active_stage, 7, Some("E:".to_owned())) + .expect("a rejected dynamic drive is not an error") + .is_empty() + ); + assert_eq!(dynamic_drive_count.load(Ordering::SeqCst), 0); + assert!( + !active_stage + .get_svc_processor::() + .expect("RDPDR channel") + .drive_hotplug_available() + ); + } + #[cfg(feature = "rdpdr")] #[test] fn dynamic_drive_changes_reach_the_rdpdr_processor() { diff --git a/crates/ironrdp-daemon/README.md b/crates/ironrdp-daemon/README.md index b69e5f5437..6332805426 100644 --- a/crates/ironrdp-daemon/README.md +++ b/crates/ironrdp-daemon/README.md @@ -16,4 +16,11 @@ Smartcard is also honored from connect/overlay property `ironrdp_smartcard`: - connect-time `ironrdp_smartcard:i:1` can enable smartcard-only without a startup flag - connect-time `ironrdp_smartcard:i:0` disables smartcard for that session +Connect properties `drivestoredirect` and `redirectdrives` select drive letters with the mstsc +syntax, as in `.rdp` files. With `*` or `DynamicDrives`, the session also announces drives +connected while it runs. Configured volumes use device IDs from `27`, because `1` to `26` belong to +drive letters. +A configured volume named like a drive letter (`C` or `C:`) cannot be combined with redirected drive +letters, because both would show the same name in the session. + Smartcard-only sessions use an empty drive list with device ID `0` reserved for the smartcard device. diff --git a/crates/ironrdp-daemon/src/daemon.rs b/crates/ironrdp-daemon/src/daemon.rs index fc05c1c71a..e6567b1eac 100644 --- a/crates/ironrdp-daemon/src/daemon.rs +++ b/crates/ironrdp-daemon/src/daemon.rs @@ -37,7 +37,9 @@ use tracing::{debug, error, info, trace, warn}; use std::collections::BTreeSet; #[cfg(windows)] -use ironrdp_rdpdr_native::{RedirectedDrive, WindowsRdpdrBackendFactory}; +use ironrdp_rdpdr_native::{ + FIRST_NAMED_DRIVE_DEVICE_ID, LogicalDriveWatcher, RedirectedDrive, WindowsRdpdrBackendFactory, +}; use crate::ipc::{ ClipboardFileEntry, ConnState, KeyFilter, KeyInput, MAX_CLIPBOARD_FILE_BYTES, MAX_CLIPBOARD_FILE_LIST_ENTRIES, @@ -367,6 +369,9 @@ struct Session { clipboard_file_notify: Arc, now_endpoint: Arc, operations: OperationManager, + /// Announces drives connected during the session (`DynamicDrives`) until the session ends. + #[cfg(windows)] + _drive_watcher: Option, } /// Converts a [`std::time::SystemTime`] to a Windows FILETIME (100-nanosecond intervals since @@ -963,6 +968,32 @@ impl Daemon { ); } }; + if let Some(value) = properties.drives_to_redirect() { + for entry in ironrdp_cfg::DriveRedirection::unrecognized_entries(value) { + warn!(entry, "Ignoring unsupported drivestoredirect entry"); + } + } + // Like printers, an explicit `ironrdp_rdpdr:i:0` disables `drivestoredirect`. + #[cfg(windows)] + let drive_redirection = properties + .drive_redirection() + .filter(|_| properties.enable_rdpdr() != Some(false)) + .unwrap_or_default(); + #[cfg(windows)] + let (rdpdr_factory, drive_selection) = + match crate::drives::logical_drive_factory(rdpdr_factory, drive_redirection) { + Ok(factory) => factory, + Err(error) => { + return Response::typed_error( + crate::ipc::AgentErrorCategory::Internal, + format!("invalid rdpdr configuration: {error:#}"), + ); + } + }; + #[cfg(not(windows))] + if properties.drive_redirection().is_some_and(|drives| !drives.is_empty()) { + warn!("Ignoring drive redirection request; it is only available on Windows"); + } #[cfg(windows)] let builder = if rdpdr_factory.is_some() { builder.with_rdpdr(true).with_smartcard(smartcard) @@ -1028,6 +1059,12 @@ impl Daemon { None => client, }; let input_tx = client.input_sender(); + #[cfg(windows)] + let drive_watcher = drive_selection.and_then(|selection| { + crate::drives::watch_logical_drives(selection, input_tx.clone()) + .inspect_err(|error| warn!(%error, "Drive hotplug is unavailable; redirecting only the drives present at connect")) + .ok() + }); let clipboard_file_notify = Arc::new(tokio::sync::Notify::new()); // State left over from an abruptly-ended previous session is meaningless against this // new one, and the remote file list plus its lock id are actively dangerous to keep: a @@ -1120,6 +1157,8 @@ impl Daemon { clipboard_file_notify, operations: OperationManager::new(Arc::clone(&now_endpoint)), now_endpoint, + #[cfg(windows)] + _drive_watcher: drive_watcher, }); Response::ok() @@ -2465,8 +2504,10 @@ fn rdpdr_backend_factory( anyhow::bail!("rdpdr volume roots must be unique"); } - let device_id = - u32::try_from(index + 1).map_err(|_| anyhow::anyhow!("too many rdpdr drive configurations"))?; + let device_id = u32::try_from(index) + .ok() + .and_then(|index| index.checked_add(FIRST_NAMED_DRIVE_DEVICE_ID)) + .ok_or_else(|| anyhow::anyhow!("too many rdpdr drive configurations"))?; RedirectedDrive::new(device_id, &drive.display_name, &drive.root_path, false) .map_err(|error| anyhow::anyhow!("invalid rdpdr drive configuration: {error}")) }) @@ -2774,6 +2815,8 @@ mod tests { clipboard_file_notify: Arc::new(tokio::sync::Notify::new()), operations: OperationManager::new(Arc::clone(&now_endpoint)), now_endpoint, + #[cfg(windows)] + _drive_watcher: None, }); (daemon, input_rx, live, rail_notify) } diff --git a/crates/ironrdp-daemon/src/drives.rs b/crates/ironrdp-daemon/src/drives.rs new file mode 100644 index 0000000000..3f2e0338fc --- /dev/null +++ b/crates/ironrdp-daemon/src/drives.rs @@ -0,0 +1,103 @@ +//! Windows drive-letter redirection shared by the daemon and the viewer. + +use std::io; + +use anyhow::Context as _; +use ironrdp_cfg::DriveRedirection; +use ironrdp_client::rdp::{RdpInputEvent, RdpInputSender}; +use ironrdp_rdpdr_native::{ + LogicalDriveChange, LogicalDriveSelection, LogicalDriveWatcher, WindowsRdpdrBackendFactory, +}; +use tokio::sync::mpsc::error::TrySendError; +use tracing::warn; + +/// Adds the drive letters selected by `drives` to a connection's RDPDR factory. +/// +/// Returns the shared selection to pass to [`watch_logical_drives`] when dynamic drives are +/// requested. Without drives, `factory` is returned unchanged. +pub fn logical_drive_factory( + factory: Option, + drives: DriveRedirection, +) -> anyhow::Result<(Option, Option)> { + if drives.is_empty() { + return Ok((factory, None)); + } + + let factory = match factory { + Some(factory) => factory, + None => WindowsRdpdrBackendFactory::from_drives(Vec::new()).context("empty drive configuration")?, + }; + let selection = LogicalDriveSelection::from_letters(drives.redirects_all(), drives.letters()); + let factory = factory + .with_logical_drives(selection.clone()) + .context("drive letters conflict with configured drives")? + .with_dynamic_drives(drives.redirects_dynamic()); + Ok((Some(factory), drives.redirects_dynamic().then_some(selection))) +} + +/// Forwards drive arrivals and removals to a session as dynamic RDPDR drive changes. +/// +/// A full input queue leaves the change to the watcher's next poll; a closed queue drops it, +/// because no session will consume it. +pub fn watch_logical_drives( + selection: LogicalDriveSelection, + input_tx: RdpInputSender, +) -> io::Result { + LogicalDriveWatcher::spawn(selection, move |change| { + let event = match change { + LogicalDriveChange::Arrived(drive) => RdpInputEvent::AddRdpdrDrive { + device_id: drive.device_id(), + name: drive.display_name(), + }, + LogicalDriveChange::Removed(drive) => RdpInputEvent::RemoveRdpdrDrive { + device_id: drive.device_id(), + }, + }; + match input_tx.try_send(event) { + Ok(()) => true, + Err(TrySendError::Full(_)) => false, + Err(TrySendError::Closed(_)) => { + warn!( + ?change, + "Dropping the drive change because the session input queue is closed" + ); + true + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drive_letters_extend_the_connect_factory() { + let system_drive = std::env::var("SystemDrive").expect("SystemDrive is set on Windows"); + let drives = DriveRedirection::parse(&system_drive); + + let (factory, selection) = logical_drive_factory(None, drives).expect("letter-only factory"); + assert_eq!(factory.expect("factory present").initial_drives().len(), 1); + assert!(selection.is_none()); + + let (factory, selection) = + logical_drive_factory(None, drives.with_dynamic(true)).expect("dynamic drive factory"); + assert!(factory.is_some()); + assert!(selection.is_some()); + + let (factory, selection) = logical_drive_factory(None, DriveRedirection::default()).expect("no drives"); + assert!(factory.is_none()); + assert!(selection.is_none()); + } + + #[test] + fn hotplug_changes_reach_the_session_queue() { + let (input_tx, mut input_rx) = RdpInputSender::channel(1); + let watcher = watch_logical_drives(LogicalDriveSelection::default(), input_tx).expect("start watcher"); + drop(watcher); + assert!( + input_rx.try_recv().is_err(), + "no change is reported without a volume transition" + ); + } +} diff --git a/crates/ironrdp-daemon/src/lib.rs b/crates/ironrdp-daemon/src/lib.rs index 7c28009779..9a64889baf 100644 --- a/crates/ironrdp-daemon/src/lib.rs +++ b/crates/ironrdp-daemon/src/lib.rs @@ -5,6 +5,8 @@ mod clipboard; pub mod daemon; +#[cfg(windows)] +pub mod drives; pub mod known_certificates; pub mod logbuf; pub mod now; diff --git a/crates/ironrdp-rdpdr-native/Cargo.toml b/crates/ironrdp-rdpdr-native/Cargo.toml index 17107a04d1..62d23e9259 100644 --- a/crates/ironrdp-rdpdr-native/Cargo.toml +++ b/crates/ironrdp-rdpdr-native/Cargo.toml @@ -29,4 +29,4 @@ ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7" } # public ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } -windows = { version = "0.62", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_SystemServices", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Graphics_Printing", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Credentials", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Threading"] } +windows = { version = "0.62", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_SystemServices", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Graphics_Printing", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Credentials", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/ironrdp-rdpdr-native/README.md b/crates/ironrdp-rdpdr-native/README.md index 7d3fc7fcdc..b42bb7e110 100644 --- a/crates/ironrdp-rdpdr-native/README.md +++ b/crates/ironrdp-rdpdr-native/README.md @@ -19,4 +19,6 @@ The Windows implementation is intentionally layered so protocol code remains platform independent in `ironrdp-rdpdr`. `WindowsRdpdrBackendFactory` configures zero or more `RedirectedDrive`s and optional default-printer discovery. -Multi-drive registry management remains outside this native backend. +A redirected drive root is a volume root (`C:\`) or a directory on one, opened without following reparse points. +`WindowsRdpdrBackendFactory::with_logical_drives` registers the drive letters (device IDs `1` to `26`) and announces the selected letters mapped when each connection starts, without probing their volumes. +`LogicalDriveWatcher` reports volumes mounted and removed during a session, from `WM_DEVICECHANGE` broadcasts and a `GetLogicalDrives` poll for network drives; hosts forward its changes as dynamic drive announcements and removals. diff --git a/crates/ironrdp-rdpdr-native/src/lib.rs b/crates/ironrdp-rdpdr-native/src/lib.rs index 700e371259..ef86c5478c 100644 --- a/crates/ironrdp-rdpdr-native/src/lib.rs +++ b/crates/ironrdp-rdpdr-native/src/lib.rs @@ -18,6 +18,7 @@ pub use nix::{backend, printer}; mod windows; #[cfg(windows)] pub use windows::{ - RdpdrWorkerThreadGuard, RdpdrWorkerThreadGuardResult, RdpdrWorkerThreadHooks, RedirectedDrive, - RedirectedDriveError, WindowsRdpdrBackend, WindowsRdpdrBackendFactory, + FIRST_NAMED_DRIVE_DEVICE_ID, LogicalDrive, LogicalDriveChange, LogicalDriveSelection, LogicalDriveSet, + LogicalDriveWatcher, RdpdrWorkerThreadGuard, RdpdrWorkerThreadGuardResult, RdpdrWorkerThreadHooks, RedirectedDrive, + RedirectedDriveError, RedirectedDriveFactoryError, WindowsRdpdrBackend, WindowsRdpdrBackendFactory, }; diff --git a/crates/ironrdp-rdpdr-native/src/windows/factory.rs b/crates/ironrdp-rdpdr-native/src/windows/factory.rs index 6bd3132d76..908af165c8 100644 --- a/crates/ironrdp-rdpdr-native/src/windows/factory.rs +++ b/crates/ironrdp-rdpdr-native/src/windows/factory.rs @@ -8,6 +8,8 @@ use ironrdp_rdpdr::{RdpdrBackendFactory, RdpdrBackendFactoryResult, RdpdrBackend use tracing::warn; use super::backend::WindowsRdpdrBackend; +use super::handles::RootDirectory; +use super::logical_drives::{LogicalDrive, LogicalDriveSelection, LogicalDriveSet}; use super::printer::{RdpdrWorkerThreadHooks, discover_default_printer}; const DEFAULT_PRINTER_DEVICE_ID: u32 = u32::MAX - 1; @@ -22,7 +24,11 @@ pub struct RedirectedDrive { } impl RedirectedDrive { - /// Creates a drive definition for one full logical Windows volume. + /// Creates a drive definition for a logical Windows volume (`C:\`) or a directory on one. + /// + /// The root must be an absolute, normalized drive-letter path: relative, `..`, verbatim + /// (`\\?\`), device, and UNC paths are rejected. The root is opened only when the drive is + /// announced, without following reparse points. pub fn new( device_id: u32, display_name: impl Into, @@ -40,11 +46,15 @@ impl RedirectedDrive { if display_name.contains('\0') { return Err(RedirectedDriveError::EmbeddedNul); } + let root_path = root_path.into(); + if RootDirectory::open(&root_path).is_err() { + return Err(RedirectedDriveError::InvalidRootPath); + } Ok(Self { device_id, display_name, - root_path: root_path.into(), + root_path, read_only, }) } @@ -79,6 +89,8 @@ pub enum RedirectedDriveError { EmptyDisplayName, /// An embedded NUL would truncate the RDPDR Unicode device name. EmbeddedNul, + /// The root is not an absolute, normalized path on a logical volume. + InvalidRootPath, } impl fmt::Display for RedirectedDriveError { @@ -87,6 +99,9 @@ impl fmt::Display for RedirectedDriveError { Self::ReservedDeviceId => f.write_str("device ID zero is reserved for RDPDR"), Self::EmptyDisplayName => f.write_str("redirected drive display name must not be empty"), Self::EmbeddedNul => f.write_str("redirected drive display name must not contain NUL"), + Self::InvalidRootPath => { + f.write_str("redirected drive root must be an absolute path on a logical volume (C:\\dir)") + } } } } @@ -105,6 +120,7 @@ impl core::error::Error for RedirectedDriveError {} pub struct WindowsRdpdrBackendFactory { drives: Vec, initial_drive_ids: Vec, + logical_drives: Option, dynamic_drives: bool, smartcard: bool, default_printer: bool, @@ -119,6 +135,7 @@ impl WindowsRdpdrBackendFactory { Self { drives: vec![drive], initial_drive_ids: vec![device_id], + logical_drives: None, dynamic_drives: false, smartcard: false, default_printer: false, @@ -159,6 +176,7 @@ impl WindowsRdpdrBackendFactory { Ok(Self { drives, initial_drive_ids, + logical_drives: None, dynamic_drives: false, smartcard: false, default_printer: false, @@ -166,6 +184,38 @@ impl WindowsRdpdrBackendFactory { }) } + /// Registers every drive letter and announces the selected letters mapped at each connection. + /// + /// Drive letters use the device IDs `1..=26` (see [`LogicalDrive::device_id`]), which must not + /// already be configured, and announce the names `A:` to `Z:`, which configured drives must + /// not use (see [`LogicalDrive::is_drive_letter_name`]). Share `selection` with a + /// [`LogicalDriveWatcher`](super::LogicalDriveWatcher) to redirect drives connected later. + pub fn with_logical_drives( + mut self, + selection: LogicalDriveSelection, + ) -> Result { + for drive in LogicalDriveSet::ALL.iter() { + if self + .drives + .iter() + .any(|configured| configured.device_id() == drive.device_id()) + { + return Err(RedirectedDriveFactoryError::DuplicateDeviceId(drive.device_id())); + } + } + if let Some(configured) = self + .drives + .iter() + .find(|configured| LogicalDrive::is_drive_letter_name(configured.display_name())) + { + return Err(RedirectedDriveFactoryError::ReservedDriveName(configured.device_id())); + } + self.drives + .extend(LogicalDriveSet::ALL.iter().map(LogicalDrive::redirected_drive)); + self.logical_drives = Some(selection); + Ok(self) + } + /// Keeps RDPDR drive capability available for later logical-volume hotplug. #[must_use] pub fn with_dynamic_drives(mut self, enabled: bool) -> Self { @@ -214,8 +264,16 @@ impl WindowsRdpdrBackendFactory { } /// Returns the initial `(device_id, name)` pair for `Rdpdr::with_drives`. + /// + /// With [`Self::with_logical_drives`], this includes the selected letters mapped now, without + /// probing their volumes. #[must_use] pub fn initial_drives(&self) -> Vec<(u32, String)> { + let logical_drives = self + .logical_drives + .as_ref() + .map(LogicalDriveSelection::mapped) + .unwrap_or_default(); self.initial_drive_ids .iter() .map(|device_id| { @@ -226,6 +284,11 @@ impl WindowsRdpdrBackendFactory { .expect("initial RDPDR drive IDs are validated when the factory is created"); (drive.device_id, drive.display_name.clone()) }) + .chain( + logical_drives + .iter() + .map(|drive| (drive.device_id(), drive.display_name())), + ) .collect() } @@ -309,6 +372,8 @@ pub enum RedirectedDriveFactoryError { RepeatedInitialSelection(u32), /// An initial drive ID has no matching configured logical volume. UnconfiguredInitialSelection(u32), + /// A configured drive uses a drive-letter name while drive letters are also redirected. + ReservedDriveName(u32), } impl fmt::Display for RedirectedDriveFactoryError { @@ -319,6 +384,10 @@ impl fmt::Display for RedirectedDriveFactoryError { Self::UnconfiguredInitialSelection(device_id) => { write!(f, "initial RDPDR device ID {device_id} is not configured") } + Self::ReservedDriveName(device_id) => write!( + f, + "RDPDR drive {device_id} uses a drive-letter name reserved for redirected drive letters" + ), } } } @@ -438,6 +507,80 @@ mod tests { assert!(product.initial_drives().is_empty()); } + #[test] + fn factory_announces_present_logical_drives() { + let system_drive = std::env::var("SystemDrive").expect("SystemDrive is set on Windows"); + let system_drive = LogicalDrive::from_letter(system_drive.chars().next().expect("nonempty system drive")) + .expect("system drive is a letter"); + let factory = WindowsRdpdrBackendFactory::from_drives(vec![ + RedirectedDrive::new(27, "home", r"C:\Users", false).expect("valid named drive"), + ]) + .expect("valid drive configuration") + .with_logical_drives(LogicalDriveSelection::new([system_drive].into_iter().collect())) + .expect("drive letters do not collide"); + + assert_eq!( + factory.initial_drives(), + vec![ + (27, "home".to_owned()), + (system_drive.device_id(), system_drive.display_name()) + ] + ); + let unannounced = LogicalDrive::from_letter(if system_drive.letter() == 'Z' { 'Y' } else { 'Z' }) + .expect("valid drive letter"); + factory + .build() + .add_drive(unannounced.device_id()) + .expect("every drive letter can be activated dynamically"); + } + + #[test] + fn logical_drives_reject_colliding_device_ids() { + let result = WindowsRdpdrBackendFactory::from_drives(vec![ + RedirectedDrive::new(3, "Data", r"D:\", false).expect("valid data drive"), + ]) + .expect("valid drive configuration") + .with_logical_drives(LogicalDriveSelection::default()); + + assert!(matches!(result, Err(RedirectedDriveFactoryError::DuplicateDeviceId(3)))); + } + + #[test] + fn logical_drives_reject_drive_letter_names() { + let named = |name| { + WindowsRdpdrBackendFactory::from_drives(vec![ + RedirectedDrive::new(27, name, r"C:\Windows", false).expect("valid named drive"), + ]) + .expect("valid drive configuration") + }; + + assert!(matches!( + named("c:").with_logical_drives(LogicalDriveSelection::default()), + Err(RedirectedDriveFactoryError::ReservedDriveName(27)) + )); + assert!( + named("Windows") + .with_logical_drives(LogicalDriveSelection::default()) + .is_ok() + ); + } + + #[test] + fn redirected_drives_reject_invalid_roots() { + for root in [ + r"relative\dir", + r"C:\Users\..\Windows", + r"\\?\C:\Users", + r"\\server\share", + ] { + assert_eq!( + RedirectedDrive::new(27, "Data", root, false), + Err(RedirectedDriveError::InvalidRootPath), + "{root}" + ); + } + } + #[test] fn factory_rejects_duplicate_device_ids() { let result = WindowsRdpdrBackendFactory::from_drives(vec![ diff --git a/crates/ironrdp-rdpdr-native/src/windows/handles.rs b/crates/ironrdp-rdpdr-native/src/windows/handles.rs index cb94611894..192a7ba4d5 100644 --- a/crates/ironrdp-rdpdr-native/src/windows/handles.rs +++ b/crates/ironrdp-rdpdr-native/src/windows/handles.rs @@ -49,16 +49,17 @@ pub(crate) const RENAME_DESTINATION_DIRECTORY_ACCESS: FILE_ACCESS_RIGHTS = FILE_ const DIRECTORY_TRAVERSAL_ACCESS: FILE_ACCESS_RIGHTS = FILE_ACCESS_RIGHTS(FILE_TRAVERSE.0 | FILE_READ_ATTRIBUTES.0 | SYNCHRONIZE.0); -/// A validated native path for a logical-volume root. +/// A validated native path for a redirected root directory. pub(crate) struct RootDirectory { nt_path: Vec, } impl RootDirectory { - /// Validates the native path of a logical-volume root. + /// Validates the native path of a logical-volume root (`C:\`) or of a directory below one + /// (`C:\Users\name`). pub(crate) fn open(path: &Path) -> Result { Ok(Self { - nt_path: volume_root_nt_path(path).ok_or(OpenDirectoryError::InvalidVolumeRoot)?, + nt_path: redirected_root_nt_path(path).ok_or(OpenDirectoryError::InvalidVolumeRoot)?, }) } @@ -191,8 +192,12 @@ impl RootDirectory { // immediately queries its metadata. MSTSC's root file object carries // this access even when the list request omits it; grant it only to // independently opened volume roots, never to descendant files. + // + // `FILE_OPEN_REPARSE_POINT` would drop `OBJ_DONT_REPARSE` for the whole root path, letting + // a junction above a directory root be followed, so it never applies to the root. let options = FileOpenOptions { desired_access: options.desired_access | FILE_READ_ATTRIBUTES.0, + create_options: options.create_options & !FILE_OPEN_REPARSE_POINT, ..options }; let mut nt_path = self.nt_path.clone(); @@ -1049,7 +1054,7 @@ pub(crate) const FILE_OVERWRITTEN_INFORMATION: usize = 3; /// Failure while opening a selected redirected-volume root. #[derive(Clone, Copy, Debug)] pub(crate) enum OpenDirectoryError { - /// The selection is not a logical Windows volume root (`C:\`). + /// The selection is not an absolute, normalized path on a logical Windows volume (`C:\`). InvalidVolumeRoot, /// The native open failed without disclosing the host path. NtStatus(NTSTATUS), @@ -1058,7 +1063,7 @@ pub(crate) enum OpenDirectoryError { impl fmt::Display for OpenDirectoryError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidVolumeRoot => f.write_str("redirected drive must be a logical volume root"), + Self::InvalidVolumeRoot => f.write_str("redirected drive must be an absolute path on a logical volume"), Self::NtStatus(status) => write!( f, "opening redirected-drive root failed with NTSTATUS {:#010X}", @@ -1070,19 +1075,45 @@ impl fmt::Display for OpenDirectoryError { impl core::error::Error for OpenDirectoryError {} -fn volume_root_nt_path(path: &Path) -> Option> { - let path = path.as_os_str().encode_wide().collect::>(); +/// Builds `\??\C:\dir` from `C:\dir`. +/// +/// Only drive-letter paths whose components are plain names are accepted, so the native path +/// names exactly what the Win32 path names: `..`, verbatim (`\\?\`), device, and UNC forms are +/// rejected, as are stream (`:`) and NUL characters. The root open itself refuses reparse points. +fn redirected_root_nt_path(path: &Path) -> Option> { + use std::path::{Component, Prefix}; - let is_logical_volume_root = path.len() == 3 - && matches!(path[0], 65..=90 | 97..=122) - && path[1] == u16::from(b':') - && path[2] == u16::from(b'\\'); - if !is_logical_volume_root { + let mut components = path.components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return None; + }; + let Prefix::Disk(letter) = prefix.kind() else { + return None; + }; + if !letter.is_ascii_alphabetic() || components.next() != Some(Component::RootDir) { return None; } let mut nt_path = r"\??\".encode_utf16().collect::>(); - nt_path.extend(path); + nt_path.extend([u16::from(letter), u16::from(b':'), u16::from(b'\\')]); + let mut separator = false; + for component in components { + let Component::Normal(name) = component else { + return None; + }; + if separator { + nt_path.push(u16::from(b'\\')); + } + let start = nt_path.len(); + nt_path.extend(name.encode_wide()); + if nt_path[start..] + .iter() + .any(|unit| *unit == 0 || *unit == u16::from(b':')) + { + return None; + } + separator = true; + } Some(nt_path) } @@ -1250,6 +1281,41 @@ mod tests { ); } + #[test] + fn redirected_roots_accept_only_normalized_drive_paths() { + let nt_path = |path: &str| redirected_root_nt_path(Path::new(path)).map(|path| String::from_utf16_lossy(&path)); + + assert_eq!(nt_path(r"C:\").as_deref(), Some(r"\??\C:\")); + assert_eq!(nt_path(r"d:\Users\name").as_deref(), Some(r"\??\D:\Users\name")); + assert_eq!(nt_path("C:/Users/name/").as_deref(), Some(r"\??\C:\Users\name")); + for rejected in [ + "", + "C:", + r"C:Users", + r"\Users", + r"Users\name", + r"C:\Users\..\Windows", + r"C:\Users\name:stream", + r"\\?\C:\Users", + r"\\.\C:\Users", + r"\\server\share", + ] { + assert_eq!(nt_path(rejected), None, "{rejected}"); + } + } + + #[test] + fn opens_a_directory_below_the_system_volume_root() { + let windows_directory = std::env::var("SystemRoot").expect("SystemRoot is set on Windows"); + let root = RootDirectory::open(Path::new(&windows_directory)).expect("open Windows directory as root"); + + assert!( + root.open_relative_directory(&RelativePath::parse(r"\System32").expect("valid relative path")) + .expect("open child directory") + .is_some() + ); + } + #[test] fn opens_the_system_volume_root() { let system_drive = std::env::var("SystemDrive").expect("SystemDrive is set on Windows"); @@ -1315,6 +1381,40 @@ mod tests { assert_eq!(information, FILE_OPENED_INFORMATION); } + #[test] + fn root_open_does_not_follow_an_ancestor_junction_with_open_reparse_point() { + let sandbox = std::env::temp_dir().join(format!("ironrdp-root-junction-{}", std::process::id())); + let target = sandbox.join("target"); + std::fs::create_dir_all(target.join("shared")).expect("create junction target"); + let junction = sandbox.join("junction"); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(&target) + .stdout(std::process::Stdio::null()) + .status() + .expect("run mklink"); + assert!(status.success(), "create test junction"); + + let root = RootDirectory::open(&junction.join("shared")).expect("validate directory root below a junction"); + let options = FileOpenOptions { + desired_access: FILE_READ_ATTRIBUTES.0 | SYNCHRONIZE.0, + allocation_size: None, + file_attributes: FILE_ATTRIBUTE_NORMAL.0, + share_access: (FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0, + create_disposition: FILE_OPEN.0, + create_options: FILE_DIRECTORY_FILE.0 | FILE_SYNCHRONOUS_IO_NONALERT.0 | FILE_OPEN_REPARSE_POINT, + }; + let result = root.open_root_file(options); + + std::fs::remove_dir(&junction).expect("remove test junction"); + std::fs::remove_dir_all(&sandbox).expect("remove test sandbox"); + assert!( + matches!(result, Err(OpenFileError::NtStatus(_))), + "the root open must not traverse the ancestor junction" + ); + } + #[test] fn root_open_allows_metadata_queries_after_an_explorer_directory_list_request() { let system_drive = std::env::var("SystemDrive").expect("SystemDrive is set on Windows"); diff --git a/crates/ironrdp-rdpdr-native/src/windows/logical_drives.rs b/crates/ironrdp-rdpdr-native/src/windows/logical_drives.rs new file mode 100644 index 0000000000..7dd69ff88b --- /dev/null +++ b/crates/ironrdp-rdpdr-native/src/windows/logical_drives.rs @@ -0,0 +1,815 @@ +//! Drive-letter selection and hotplug notifications for Windows RDPDR redirection. + +use core::fmt; +use core::sync::atomic::{AtomicU32, Ordering}; +use std::io; +use std::path::PathBuf; +use std::sync::{Arc, mpsc}; +use std::thread::JoinHandle; + +use tracing::{debug, warn}; +use windows::Win32::Foundation::{ERROR_CLASS_ALREADY_EXISTS, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives, GetVolumeInformationW}; +use windows::Win32::System::Diagnostics::Debug::{SEM_FAILCRITICALERRORS, SetThreadErrorMode, THREAD_ERROR_MODE}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Threading::GetCurrentThreadId; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DBT_DEVICEARRIVAL, DBT_DEVTYP_VOLUME, DEV_BROADCAST_HDR, DEV_BROADCAST_VOLUME, DefWindowProcW, + DestroyWindow, DispatchMessageW, GetMessageW, MSG, PostMessageW, PostThreadMessageW, RegisterClassW, SetTimer, + WM_APP, WM_DEVICECHANGE, WM_QUIT, WM_TIMER, WNDCLASSW, WS_EX_TOOLWINDOW, WS_OVERLAPPED, +}; +use windows::core::{HSTRING, PCWSTR, w}; + +use super::factory::RedirectedDrive; + +const LOGICAL_DRIVE_COUNT: u8 = 26; +/// First RDPDR device ID for drives other than drive letters, which own `1..=26`. +/// +/// INVARIANT: one more than the device ID of `Z:`. +pub const FIRST_NAMED_DRIVE_DEVICE_ID: u32 = 27; +const ALL_LOGICAL_DRIVES: u32 = (1 << LOGICAL_DRIVE_COUNT) - 1; +/// Network drives mapped with `net use` do not broadcast `WM_DEVICECHANGE`. +const LOGICAL_DRIVE_POLL_INTERVAL_MS: u32 = 2_000; +/// Polls between probes of mapped letters whose volume is not ready, such as a network share that +/// is reachable again after a VPN reconnect. A probe of an unreachable share can block, so it runs +/// far less often than the letter-mask poll. +const UNREADY_RETRY_POLLS: u32 = 8; +const WM_RESCAN_LOGICAL_DRIVES: u32 = WM_APP + 1; + +/// A local logical volume identified by its drive letter. +/// +/// Drive letters own the RDPDR device IDs `1..=26` (`A:` to `Z:`), so a letter keeps the same ID +/// across hotplug events and reconnects. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LogicalDrive { + /// INVARIANT: `index < LOGICAL_DRIVE_COUNT`. + index: u8, +} + +impl LogicalDrive { + /// Returns the logical drive for an ASCII drive letter, in either case. + pub fn from_letter(letter: char) -> Option { + let index = u8::try_from(letter.to_ascii_uppercase()).ok()?.checked_sub(b'A')?; + (index < LOGICAL_DRIVE_COUNT).then_some(Self { index }) + } + + /// Returns whether `name` is a drive-letter name (`C` or `C:`), which logical drives announce. + #[must_use] + pub fn is_drive_letter_name(name: &str) -> bool { + matches!(name.as_bytes(), [letter] | [letter, b':'] if letter.is_ascii_alphabetic()) + } + + /// Returns the logical drive owning an RDPDR device ID. + pub fn from_device_id(device_id: u32) -> Option { + let index = u8::try_from(device_id.checked_sub(1)?).ok()?; + (index < LOGICAL_DRIVE_COUNT).then_some(Self { index }) + } + + /// Returns the uppercase drive letter. + #[must_use] + pub fn letter(self) -> char { + char::from(b'A' + self.index) + } + + /// Returns the RDPDR filesystem device ID reserved for this letter. + #[must_use] + pub fn device_id(self) -> u32 { + u32::from(self.index) + 1 + } + + /// Returns the drive name announced to the server (`C:`). + #[must_use] + pub fn display_name(self) -> String { + format!("{}:", self.letter()) + } + + /// Returns the volume root path (`C:\`). + #[must_use] + pub fn root_path(self) -> PathBuf { + PathBuf::from(format!(r"{}:\", self.letter())) + } + + /// Returns the drive definition registered with a backend factory. + #[must_use] + pub fn redirected_drive(self) -> RedirectedDrive { + RedirectedDrive::new(self.device_id(), self.display_name(), self.root_path(), false) + .expect("logical drive IDs are nonzero and their names are nonempty") + } + + fn bit(self) -> u32 { + 1 << self.index + } +} + +impl fmt::Display for LogicalDrive { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:", self.letter()) + } +} + +/// A set of logical drive letters. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LogicalDriveSet { + /// INVARIANT: only the low `LOGICAL_DRIVE_COUNT` bits are set. + mask: u32, +} + +impl LogicalDriveSet { + /// Every drive letter from `A:` to `Z:`. + pub const ALL: Self = Self { + mask: ALL_LOGICAL_DRIVES, + }; + + /// Returns the redirectable volumes currently present. + /// + /// A volume is present when it has a root directory and a mounted file system, so empty + /// optical or card-reader drives are skipped until media is inserted. + pub fn present() -> Self { + Self::ALL.present_subset() + } + + /// Returns the drives of `self` that are present, probing only those volumes. + fn present_subset(self) -> Self { + // SAFETY: `GetLogicalDrives` takes no arguments. + let mask = unsafe { GetLogicalDrives() } & ALL_LOGICAL_DRIVES; + if mask == 0 { + warn!("Unable to enumerate logical drives"); + } + Self::from_mask(mask & self.mask) + .iter() + .filter(|drive| volume_is_ready(*drive)) + .collect() + } + + fn from_mask(mask: u32) -> Self { + Self { + mask: mask & ALL_LOGICAL_DRIVES, + } + } + + /// Returns whether `drive` is in the set. + #[must_use] + pub fn contains(self, drive: LogicalDrive) -> bool { + self.mask & drive.bit() != 0 + } + + /// Adds `drive` to the set. + pub fn insert(&mut self, drive: LogicalDrive) { + self.mask |= drive.bit(); + } + + /// Removes `drive` from the set. + pub fn remove(&mut self, drive: LogicalDrive) { + self.mask &= !drive.bit(); + } + + /// Returns whether the set is empty. + #[must_use] + pub fn is_empty(self) -> bool { + self.mask == 0 + } + + /// Returns the drives in both sets. + #[must_use] + pub fn intersection(self, other: Self) -> Self { + Self::from_mask(self.mask & other.mask) + } + + /// Returns the drives in either set. + #[must_use] + pub fn union(self, other: Self) -> Self { + Self::from_mask(self.mask | other.mask) + } + + /// Returns the drives in `self` but not in `other`. + #[must_use] + pub fn difference(self, other: Self) -> Self { + Self::from_mask(self.mask & !other.mask) + } + + /// Iterates the drives in alphabetical order. + pub fn iter(self) -> impl Iterator { + (0..LOGICAL_DRIVE_COUNT) + .map(|index| LogicalDrive { index }) + .filter(move |drive| self.contains(*drive)) + } +} + +impl FromIterator for LogicalDriveSet { + fn from_iter>(iter: T) -> Self { + let mut set = Self::default(); + for drive in iter { + set.insert(drive); + } + set + } +} + +/// Drive letters selected for redirection, shared by a backend factory and its hotplug watcher. +/// +/// The factory announces the selected letters that are mapped when each connection starts. +/// A [`LogicalDriveWatcher`] adds letters that arrive during the session, so a reconnect keeps +/// announcing them. +#[derive(Clone, Debug, Default)] +pub struct LogicalDriveSelection { + mask: Arc, +} + +impl LogicalDriveSelection { + /// Creates a selection of `drives`. + pub fn new(drives: LogicalDriveSet) -> Self { + Self { + mask: Arc::new(AtomicU32::new(drives.mask)), + } + } + + /// Creates a selection of every letter, or of the given letters; non-letters are ignored. + pub fn from_letters(all: bool, letters: impl IntoIterator) -> Self { + Self::new(if all { + LogicalDriveSet::ALL + } else { + letters.into_iter().filter_map(LogicalDrive::from_letter).collect() + }) + } + + /// Returns the selected letters. + #[must_use] + pub fn get(&self) -> LogicalDriveSet { + LogicalDriveSet::from_mask(self.mask.load(Ordering::Acquire)) + } + + /// Adds `drive` to the selection. + pub fn insert(&self, drive: LogicalDrive) { + self.mask.fetch_or(drive.bit(), Ordering::AcqRel); + } + + /// Returns the selected letters that are mapped now, without probing their volumes. + /// + /// This only reads the logical-drive mask, so it never blocks on an unreachable network share + /// or an empty removable drive. Like mstsc, such drives are announced and their requests fail + /// until the volume is available. + #[must_use] + pub fn mapped(&self) -> LogicalDriveSet { + // SAFETY: `GetLogicalDrives` takes no arguments. + let mask = unsafe { GetLogicalDrives() }; + self.get().intersection(LogicalDriveSet::from_mask(mask)) + } +} + +/// A change in the selected logical drives reported by [`LogicalDriveWatcher`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogicalDriveChange { + /// A volume was mounted under this letter. + Arrived(LogicalDrive), + /// A selected volume was removed. + Removed(LogicalDrive), +} + +/// Watches for volumes mounted and removed during a session (dynamic drives). +/// +/// A hidden window on a dedicated thread receives the `WM_DEVICECHANGE` volume broadcasts, and a +/// short `GetLogicalDrives` poll catches network drives, which are not broadcast. Mapped letters +/// whose volume is not ready are probed again periodically. +/// +/// A volume counts as connected later when its letter is selected, was not mapped when the watcher +/// started, or is announced by a volume arrival broadcast (a USB stick or inserted media). Such +/// arrivals are added to the shared selection and reported; a removal is reported when the letter +/// is selected. A network share mapped before the session that only becomes reachable later is not +/// redirected unless selected. +/// +/// The callback must not block. It returns `false` when it could not deliver a change, which is +/// then reported again on the next poll. Dropping the watcher stops the thread. +pub struct LogicalDriveWatcher { + thread_id: u32, + thread: Option>, +} + +impl LogicalDriveWatcher { + /// Starts watching, reporting changes relative to the volumes present now. + pub fn spawn( + selection: LogicalDriveSelection, + on_change: impl FnMut(LogicalDriveChange) -> bool + Send + 'static, + ) -> io::Result { + let (ready_sender, ready_receiver) = mpsc::sync_channel(1); + let thread = std::thread::Builder::new() + .name("ironrdp-rdpdr-drive-watcher".to_owned()) + .spawn(move || run_watcher(selection, Box::new(on_change), ready_sender))?; + match ready_receiver.recv() { + Ok(Ok(thread_id)) => Ok(Self { + thread_id, + thread: Some(thread), + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(error) + } + Err(_) => { + let _ = thread.join(); + Err(io::Error::other("logical drive watcher thread exited during startup")) + } + } + } +} + +impl fmt::Debug for LogicalDriveWatcher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LogicalDriveWatcher") + .field("thread_id", &self.thread_id) + .finish_non_exhaustive() + } +} + +impl Drop for LogicalDriveWatcher { + fn drop(&mut self) { + // SAFETY: posting a message to a thread ID has no memory-safety preconditions. + if let Err(error) = unsafe { PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)) } { + warn!(%error, "Unable to stop the logical drive watcher"); + return; + } + if let Some(thread) = self.thread.take() + && thread.join().is_err() + { + warn!("Logical drive watcher thread panicked"); + } + } +} + +type ChangeCallback = Box bool + Send>; + +fn run_watcher( + selection: LogicalDriveSelection, + mut on_change: ChangeCallback, + ready: mpsc::SyncSender>, +) { + let window = match create_watcher_window() { + Ok(window) => window, + Err(error) => { + let _ = ready.send(Err(error)); + return; + } + }; + // SAFETY: `window` is a live window owned by this thread, and no timer callback is used. + if unsafe { SetTimer(Some(window), 1, LOGICAL_DRIVE_POLL_INTERVAL_MS, None) } == 0 { + warn!(error = %io::Error::last_os_error(), "Unable to start logical drive polling"); + } + + // SAFETY: `GetLogicalDrives` takes no arguments. + let mut letters = unsafe { GetLogicalDrives() }; + // Letters mapped before the session are not "connected later" until they are unmapped or + // announced by a volume arrival broadcast. + let mut preexisting = LogicalDriveSet::from_mask(letters); + // Report readiness before probing volumes, which can block on unreachable network shares. + // SAFETY: `GetCurrentThreadId` takes no arguments. The thread has a message queue because it + // owns `window`, so `WM_QUIT` posted to this ID reaches the loop below. + let _ = ready.send(Ok(unsafe { GetCurrentThreadId() })); + let mut present = LogicalDriveSet::present(); + debug!(drives = ?present.iter().map(LogicalDrive::letter).collect::(), "Watching logical drives"); + + let mut polls = 0u32; + let mut undelivered = false; + let mut message = MSG::default(); + loop { + // SAFETY: `message` is a valid writable `MSG`. + let result = unsafe { GetMessageW(&mut message, None, 0, 0) }; + if result.0 == 0 { + break; + } + if result.0 == -1 { + warn!(error = %io::Error::last_os_error(), "Logical drive watcher message loop failed"); + break; + } + + let rescan = match message.message { + WM_RESCAN_LOGICAL_DRIVES => true, + WM_TIMER => { + polls = polls.wrapping_add(1); + false + } + _ => { + // SAFETY: `message` was filled by `GetMessageW`. + unsafe { DispatchMessageW(&message) }; + continue; + } + }; + + // SAFETY: `GetLogicalDrives` takes no arguments. + let current_letters = unsafe { GetLogicalDrives() }; + let arrived = if rescan { + LogicalDriveSet::from_mask(u32::try_from(message.wParam.0).unwrap_or(0)) + } else { + LogicalDriveSet::default() + }; + preexisting = preexisting + .intersection(LogicalDriveSet::from_mask(current_letters)) + .difference(arrived); + let ineligible = preexisting.difference(selection.get()); + // On a poll, probe every volume only when the letter mask changes or a change is + // undelivered: the probe can block on slow network drives, and media changes are broadcast. + let letters_changed = core::mem::replace(&mut letters, current_letters) != current_letters; + let current = if rescan || letters_changed || undelivered { + Some(LogicalDriveSet::present()) + } else if polls.is_multiple_of(UNREADY_RETRY_POLLS) { + let unready = LogicalDriveSet::from_mask(current_letters) + .difference(present) + .difference(ineligible); + (!unready.is_empty()).then(|| present.union(unready.present_subset())) + } else { + None + }; + if let Some(current) = current { + (present, undelivered) = report_changes(present, current, ineligible, &selection, &mut on_change); + } + } + + // SAFETY: `window` is owned by this thread and has not been destroyed. + if let Err(error) = unsafe { DestroyWindow(window) } { + debug!(%error, "Unable to destroy the logical drive watcher window"); + } +} + +/// Reports the transition from `previous` to `current` and returns the volumes now known to the +/// receiver, plus whether a change was not delivered. +/// +/// Arrivals in `ineligible` are recorded without being reported. An undelivered change keeps its +/// drive in the previous state, so the next scan reports it again. +fn report_changes( + previous: LogicalDriveSet, + current: LogicalDriveSet, + ineligible: LogicalDriveSet, + selection: &LogicalDriveSelection, + on_change: &mut dyn FnMut(LogicalDriveChange) -> bool, +) -> (LogicalDriveSet, bool) { + let mut reported = previous; + let mut undelivered = false; + for drive in current.difference(previous).iter() { + if ineligible.contains(drive) { + reported.insert(drive); + continue; + } + selection.insert(drive); + debug!(%drive, "Logical drive arrived"); + if on_change(LogicalDriveChange::Arrived(drive)) { + reported.insert(drive); + } else { + undelivered = true; + } + } + for drive in previous.difference(current).iter() { + if selection.get().contains(drive) { + debug!(%drive, "Logical drive removed"); + if !on_change(LogicalDriveChange::Removed(drive)) { + undelivered = true; + continue; + } + } + reported.remove(drive); + } + (reported, undelivered) +} + +fn create_watcher_window() -> io::Result { + const CLASS_NAME: PCWSTR = w!("IronRdpRdpdrLogicalDriveWatcher"); + + // SAFETY: a null module name selects the current executable, which outlives the window. + let instance = unsafe { GetModuleHandleW(None) }.map_err(io::Error::from)?; + let class = WNDCLASSW { + lpfnWndProc: Some(watcher_window_proc), + hInstance: instance.into(), + lpszClassName: CLASS_NAME, + ..Default::default() + }; + // SAFETY: `class` is fully initialized and its class name is a static string. + if unsafe { RegisterClassW(&class) } == 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(i32::try_from(ERROR_CLASS_ALREADY_EXISTS.0).expect("error code fits in i32")) { + return Err(error); + } + } + + // Volume broadcasts only reach top-level windows, so this cannot be a message-only window. + // SAFETY: the class is registered for this module and every pointer argument is valid or null. + unsafe { + CreateWindowExW( + WS_EX_TOOLWINDOW, + CLASS_NAME, + w!("IronRDP drive watcher"), + WS_OVERLAPPED, + 0, + 0, + 0, + 0, + None, + None, + Some(instance.into()), + None, + ) + } + .map_err(io::Error::from) +} + +extern "system" fn watcher_window_proc(window: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if message == WM_DEVICECHANGE { + let arrived = if usize::try_from(DBT_DEVICEARRIVAL).is_ok_and(|event| wparam.0 == event) { + // SAFETY: for `DBT_DEVICEARRIVAL`, `lparam` is null or points to a broadcast header that + // stays valid while this message is processed. + unsafe { arrived_volume_mask(lparam) } + } else { + 0 + }; + // Broadcasts are sent, not posted, so queue the rescan for the message loop. + // SAFETY: `window` is the live window receiving this message. + if let Err(error) = unsafe { + PostMessageW( + Some(window), + WM_RESCAN_LOGICAL_DRIVES, + WPARAM(usize::try_from(arrived).unwrap_or(0)), + LPARAM(0), + ) + } { + warn!(%error, "Unable to queue a logical drive rescan"); + } + return LRESULT(1); + } + // SAFETY: forwards the unmodified message parameters to the default window procedure. + unsafe { DefWindowProcW(window, message, wparam, lparam) } +} + +/// Returns the drive letters announced by a `DBT_DEVICEARRIVAL` volume broadcast. +/// +/// # Safety +/// +/// `lparam` must be null or point to a valid `DEV_BROADCAST_HDR` followed by `dbch_size` bytes. +unsafe fn arrived_volume_mask(lparam: LPARAM) -> u32 { + let header = core::ptr::with_exposed_provenance::(usize::from_ne_bytes(lparam.0.to_ne_bytes())); + if header.is_null() { + return 0; + } + // SAFETY: the caller guarantees that a non-null `header` points to a broadcast header. + let header_value = unsafe { header.read_unaligned() }; + let is_volume = header_value.dbch_devicetype == DBT_DEVTYP_VOLUME + && usize::try_from(header_value.dbch_size).is_ok_and(|size| size_of::() <= size); + if !is_volume { + return 0; + } + // SAFETY: a volume broadcast of at least this size is a `DEV_BROADCAST_VOLUME`. + unsafe { header.cast::().read_unaligned() }.dbcv_unitmask & ALL_LOGICAL_DRIVES +} + +fn volume_is_ready(drive: LogicalDrive) -> bool { + let root = HSTRING::from(drive.root_path().as_os_str()); + // Fail instead of showing "insert a disk" dialogs for empty removable drives. + let mut previous_mode = THREAD_ERROR_MODE::default(); + // SAFETY: `previous_mode` is a valid writable error-mode slot. + let restore = unsafe { SetThreadErrorMode(SEM_FAILCRITICALERRORS, Some(&mut previous_mode)) }.is_ok(); + + // Types 0 and 1 are DRIVE_UNKNOWN and DRIVE_NO_ROOT_DIR. + // SAFETY: `root` is a NUL-terminated path that outlives the call. + let ready = unsafe { GetDriveTypeW(&root) } > 1 + // SAFETY: `root` outlives the call, and no output buffer is requested. + && unsafe { GetVolumeInformationW(&root, None, None, None, None, None) }.is_ok(); + + if restore { + // SAFETY: restores the mode returned by the previous call. + let _ = unsafe { SetThreadErrorMode(previous_mode, None) }; + } + ready +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logical_drives_own_stable_device_ids() { + let drive = LogicalDrive::from_letter('c').expect("valid drive letter"); + assert_eq!(drive.letter(), 'C'); + assert_eq!(drive.device_id(), 3); + assert_eq!(drive.display_name(), "C:"); + assert_eq!(drive.root_path(), PathBuf::from(r"C:\")); + assert_eq!(LogicalDrive::from_device_id(3), Some(drive)); + assert_eq!(LogicalDrive::from_device_id(1), LogicalDrive::from_letter('A')); + assert_eq!(LogicalDrive::from_device_id(26), LogicalDrive::from_letter('Z')); + + for invalid in ['@', '[', '1', '\u{e9}'] { + assert_eq!(LogicalDrive::from_letter(invalid), None, "{invalid}"); + } + for invalid in [0, FIRST_NAMED_DRIVE_DEVICE_ID, u32::MAX] { + assert_eq!(LogicalDrive::from_device_id(invalid), None, "{invalid}"); + } + assert_eq!( + LogicalDrive::from_letter('Z').expect("valid drive letter").device_id() + 1, + FIRST_NAMED_DRIVE_DEVICE_ID + ); + } + + #[test] + fn drive_letter_names_are_recognized() { + assert!(LogicalDrive::is_drive_letter_name("C")); + assert!(LogicalDrive::is_drive_letter_name("z:")); + assert!(!LogicalDrive::is_drive_letter_name("home")); + assert!(!LogicalDrive::is_drive_letter_name(r"C:\")); + assert!(!LogicalDrive::is_drive_letter_name("1:")); + } + + #[test] + fn selections_are_built_from_letters() { + let selection = LogicalDriveSelection::from_letters(false, ['c', '1', 'E']); + assert_eq!( + selection.get().iter().map(LogicalDrive::letter).collect::(), + "CE" + ); + assert_eq!( + LogicalDriveSelection::from_letters(true, []).get(), + LogicalDriveSet::ALL + ); + } + + #[test] + fn logical_drive_sets_compare_letters() { + let letter = |letter| LogicalDrive::from_letter(letter).expect("valid drive letter"); + let before = [letter('C'), letter('E')].into_iter().collect::(); + let after = [letter('C'), letter('F')].into_iter().collect::(); + + assert_eq!(after.difference(before).iter().collect::>(), [letter('F')]); + assert_eq!(before.difference(after).iter().collect::>(), [letter('E')]); + assert_eq!(before.intersection(after).iter().collect::>(), [letter('C')]); + assert_eq!(LogicalDriveSet::ALL.iter().count(), 26); + } + + #[test] + fn present_logical_drives_include_the_system_drive() { + let system_drive = std::env::var("SystemDrive").expect("SystemDrive is set on Windows"); + let drive = LogicalDrive::from_letter(system_drive.chars().next().expect("nonempty system drive")) + .expect("system drive is a letter"); + + assert!(LogicalDriveSet::present().contains(drive)); + assert!( + LogicalDriveSelection::new(LogicalDriveSet::ALL) + .mapped() + .contains(drive) + ); + assert!(LogicalDriveSelection::default().mapped().is_empty()); + } + + #[test] + fn undelivered_changes_are_reported_again() { + let letter = |letter| LogicalDrive::from_letter(letter).expect("valid drive letter"); + let selection = LogicalDriveSelection::new([letter('C'), letter('E')].into_iter().collect()); + let previous = [letter('C'), letter('E')].into_iter().collect::(); + let current = [letter('C'), letter('F')].into_iter().collect::(); + + let mut changes = Vec::new(); + let (reported, undelivered) = report_changes( + previous, + current, + LogicalDriveSet::default(), + &selection, + &mut |change| { + changes.push(change); + false + }, + ); + assert!(undelivered); + assert_eq!(reported, previous); + assert_eq!( + changes, + [ + LogicalDriveChange::Arrived(letter('F')), + LogicalDriveChange::Removed(letter('E')) + ] + ); + assert!(selection.get().contains(letter('F'))); + + changes.clear(); + let (reported, undelivered) = report_changes( + reported, + current, + LogicalDriveSet::default(), + &selection, + &mut |change| { + changes.push(change); + true + }, + ); + assert!(!undelivered); + assert_eq!(reported, current); + assert_eq!(changes.len(), 2); + } + + #[test] + fn unselected_removals_are_not_reported() { + let letter = |letter| LogicalDrive::from_letter(letter).expect("valid drive letter"); + let previous = [letter('C'), letter('E')].into_iter().collect::(); + let current = [letter('C')].into_iter().collect::(); + + let (reported, undelivered) = report_changes( + previous, + current, + LogicalDriveSet::default(), + &LogicalDriveSelection::new(current), + &mut |change| panic!("unexpected {change:?}"), + ); + assert!(!undelivered); + assert_eq!(reported, current); + } + + #[test] + fn ineligible_arrivals_are_recorded_without_redirecting() { + let letter = |letter| LogicalDrive::from_letter(letter).expect("valid drive letter"); + let selection = LogicalDriveSelection::new([letter('C')].into_iter().collect()); + let previous = [letter('C')].into_iter().collect::(); + let current = [letter('C'), letter('Z')].into_iter().collect::(); + + let (reported, undelivered) = report_changes( + previous, + current, + [letter('Z')].into_iter().collect(), + &selection, + &mut |change| panic!("unexpected {change:?}"), + ); + assert!(!undelivered); + assert_eq!(reported, current); + assert!(!selection.get().contains(letter('Z'))); + } + + #[test] + fn volume_arrival_broadcasts_report_their_letters() { + let broadcast = DEV_BROADCAST_VOLUME { + dbcv_size: u32::try_from(size_of::()).expect("broadcast size fits in u32"), + dbcv_devicetype: DBT_DEVTYP_VOLUME.0, + dbcv_reserved: 0, + dbcv_unitmask: 0b10100, + dbcv_flags: Default::default(), + }; + let lparam = LPARAM(isize::from_ne_bytes( + core::ptr::from_ref(&broadcast).expose_provenance().to_ne_bytes(), + )); + + // SAFETY: `lparam` points to a live volume broadcast. + assert_eq!(unsafe { arrived_volume_mask(lparam) }, 0b10100); + // SAFETY: a null broadcast pointer is accepted. + assert_eq!(unsafe { arrived_volume_mask(LPARAM(0)) }, 0); + } + + #[test] + fn selection_is_shared_between_clones() { + let selection = LogicalDriveSelection::default(); + let drive = LogicalDrive::from_letter('Q').expect("valid drive letter"); + selection.clone().insert(drive); + assert!(selection.get().contains(drive)); + } + + #[test] + fn watcher_starts_and_stops() { + let watcher = LogicalDriveWatcher::spawn(LogicalDriveSelection::default(), |_| true).expect("start watcher"); + drop(watcher); + } + + /// Maps and unmaps a free drive letter with `subst`, which changes the user's drive mappings. + #[test] + #[ignore = "changes the user's drive letters"] + fn watcher_reports_subst_drive_arrival_and_removal() { + use core::time::Duration; + use std::process::Command; + + let used = LogicalDriveSet::from_mask( + // SAFETY: `GetLogicalDrives` takes no arguments. + unsafe { GetLogicalDrives() }, + ); + let drive = LogicalDriveSet::ALL + .difference(used) + .iter() + .last() + .expect("a free drive letter"); + let target = std::env::temp_dir(); + let (sender, receiver) = mpsc::channel(); + let selection = LogicalDriveSelection::default(); + let watcher = LogicalDriveWatcher::spawn(selection.clone(), move |change| { + let _ = sender.send(change); + true + }) + .expect("start watcher"); + + let status = Command::new("subst") + .arg(drive.to_string()) + .arg(&target) + .status() + .expect("run subst"); + assert!(status.success()); + let arrived = receiver.recv_timeout(Duration::from_secs(10)); + let status = Command::new("subst") + .arg(drive.to_string()) + .arg("/D") + .status() + .expect("run subst /D"); + assert!(status.success()); + + assert_eq!(arrived, Ok(LogicalDriveChange::Arrived(drive))); + assert!(selection.get().contains(drive)); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(10)), + Ok(LogicalDriveChange::Removed(drive)) + ); + drop(watcher); + } +} diff --git a/crates/ironrdp-rdpdr-native/src/windows/mod.rs b/crates/ironrdp-rdpdr-native/src/windows/mod.rs index 2f62bd137d..f81e8065ae 100644 --- a/crates/ironrdp-rdpdr-native/src/windows/mod.rs +++ b/crates/ironrdp-rdpdr-native/src/windows/mod.rs @@ -6,6 +6,7 @@ mod file; mod file_table; mod handles; mod locks; +mod logical_drives; mod path; mod pending; mod printer; @@ -15,5 +16,9 @@ mod status; mod volume; pub use backend::WindowsRdpdrBackend; -pub use factory::{RedirectedDrive, RedirectedDriveError, WindowsRdpdrBackendFactory}; +pub use factory::{RedirectedDrive, RedirectedDriveError, RedirectedDriveFactoryError, WindowsRdpdrBackendFactory}; +pub use logical_drives::{ + FIRST_NAMED_DRIVE_DEVICE_ID, LogicalDrive, LogicalDriveChange, LogicalDriveSelection, LogicalDriveSet, + LogicalDriveWatcher, +}; pub use printer::{RdpdrWorkerThreadGuard, RdpdrWorkerThreadGuardResult, RdpdrWorkerThreadHooks}; diff --git a/crates/ironrdp-testsuite-core/tests/cfg.rs b/crates/ironrdp-testsuite-core/tests/cfg.rs index 16a57b55c4..e1a61f6030 100644 --- a/crates/ironrdp-testsuite-core/tests/cfg.rs +++ b/crates/ironrdp-testsuite-core/tests/cfg.rs @@ -53,3 +53,119 @@ fn display_roundtrip(#[case] input: &str, #[case] expected: &str) { let addr: TargetAddr = input.parse().unwrap(); assert_eq!(addr.to_string(), expected); } + +// -- DriveRedirection ------------------------------------------------------------- + +#[rstest] +#[case("", false, false, "")] +#[case("*", true, true, "")] +#[case("DynamicDrives", false, true, "")] +#[case("dynamicdrives;", false, true, "")] +#[case(r"C:\;E:\;", false, false, "CE")] +#[case("e:;c;D:/", false, false, "CDE")] +#[case(r" C:\ ; DynamicDrives ", false, true, "C")] +// Unrecognized entries (FreeRDP paths, devices, labels) are ignored. +#[case(r"C:\Users;%;DynamicDevices;CD:\;1:\", false, false, "")] +fn drive_redirection_parse(#[case] input: &str, #[case] all: bool, #[case] dynamic: bool, #[case] letters: &str) { + let drives = ironrdp_cfg::DriveRedirection::parse(input); + assert_eq!(drives.redirects_all(), all); + assert_eq!(drives.redirects_dynamic(), dynamic); + assert_eq!(drives.letters().collect::(), letters); +} + +#[rstest] +#[case("", &[])] +#[case(r"*;DynamicDrives;c;D:\;e:/", &[])] +#[case(r" C:\Users ; % ;DynamicDevices;CD:\;", &[r"C:\Users", "%", "DynamicDevices", r"CD:\"])] +fn drive_redirection_unrecognized_entries(#[case] input: &str, #[case] expected: &[&str]) { + assert_eq!( + ironrdp_cfg::DriveRedirection::unrecognized_entries(input).collect::>(), + expected + ); +} + +#[rstest] +#[case("", "")] +#[case("*", "*")] +#[case(r"C:\;*", "*")] +#[case("e:;c", r"C:\;E:\")] +#[case("DynamicDrives;D", r"D:\;DynamicDrives")] +fn drive_redirection_display(#[case] input: &str, #[case] expected: &str) { + let drives = ironrdp_cfg::DriveRedirection::parse(input); + assert_eq!(drives.to_string(), expected); + assert_eq!(ironrdp_cfg::DriveRedirection::parse(&drives.to_string()), drives); +} + +#[test] +fn drive_redirection_dynamic_drives() { + assert!( + !ironrdp_cfg::DriveRedirection::parse("D") + .with_dynamic(false) + .redirects_dynamic() + ); + assert!( + ironrdp_cfg::DriveRedirection::parse("D") + .with_dynamic(true) + .redirects_dynamic() + ); + assert!( + ironrdp_cfg::DriveRedirection::all() + .with_dynamic(false) + .redirects_dynamic() + ); +} + +// -- PropertySetExt::drive_redirection ---------------------------------------------- + +#[rstest] +#[case(None, None, None)] +#[case(Some(""), None, Some(""))] +#[case(None, Some(false), Some(""))] +#[case(None, Some(true), Some("*"))] +#[case(Some(""), Some(true), Some("*"))] +#[case(Some(" "), Some(true), Some("*"))] +#[case(Some(r"C:\"), Some(true), Some(r"C:\"))] +// An explicit opt-out wins over a stale drive list. +#[case(Some(r"C:\"), Some(false), Some(""))] +#[case(Some("*"), Some(false), Some(""))] +fn property_set_drive_redirection( + #[case] drives_to_redirect: Option<&str>, + #[case] redirect_drives: Option, + #[case] expected: Option<&str>, +) { + use ironrdp_cfg::PropertySetExt as _; + + let mut properties = ironrdp_propertyset::PropertySet::new(); + if let Some(value) = drives_to_redirect { + properties.set_drives_to_redirect(value); + } + if let Some(value) = redirect_drives { + properties.set_redirect_drives(value); + } + + assert_eq!( + properties.drive_redirection(), + expected.map(ironrdp_cfg::DriveRedirection::parse) + ); +} + +#[test] +fn property_set_drive_redirection_round_trips_through_rdp_file() { + use ironrdp_cfg::PropertySetExt as _; + + let mut properties = ironrdp_propertyset::PropertySet::new(); + ironrdp_rdpfile::load( + &mut properties, + concat!(r"drivestoredirect:s:C:\;DynamicDrives", "\r\nredirectdrives:i:1\r\n"), + ) + .expect("valid .rdp file"); + let drives = properties.drive_redirection().expect("drive selection is present"); + assert_eq!(drives.letters().collect::(), "C"); + assert!(drives.redirects_dynamic()); + + let mut written = ironrdp_propertyset::PropertySet::new(); + written.set_drive_redirection(drives); + let content = ironrdp_rdpfile::write(&written); + assert!(content.contains(r"drivestoredirect:s:C:\;DynamicDrives"), "{content}"); + assert!(content.contains("redirectdrives:i:1"), "{content}"); +} diff --git a/crates/ironrdp-testsuite-extra/tests/client/config.rs b/crates/ironrdp-testsuite-extra/tests/client/config.rs index 680e50f8fd..f83188fd1b 100644 --- a/crates/ironrdp-testsuite-extra/tests/client/config.rs +++ b/crates/ironrdp-testsuite-extra/tests/client/config.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use ironrdp_cfg::GatewayCredentialsSource; use ironrdp_cfg::PropertySetExt as _; use ironrdp_client::config::{ - AudioQualityMode, ClipboardType, ConfigBuilder, Destination, Transport, UdpVersion, VmConnectMode, + AudioQualityMode, ClipboardType, ConfigBuilder, Destination, DriveRedirection, Transport, UdpVersion, VmConnectMode, }; #[cfg(windows)] use ironrdp_client::config::{MissingField, TransportKind}; @@ -495,6 +495,152 @@ ClearTextPassword:s:test-pass ); } +const DRIVE_TEST_RDP: &str = "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\n"; + +#[test] +fn rdp_file_selects_drives_to_redirect() { + let config = parse_config_from_rdp( + &format!("{DRIVE_TEST_RDP}drivestoredirect:s:C:\\;e:\\;DynamicDrives\nredirectdrives:i:1\n"), + &[], + ); + + let drives = config.channels().rdpdr.drives; + assert!(!drives.redirects_all()); + assert!(drives.redirects_dynamic()); + assert_eq!(drives.letters().collect::(), "CE"); +} + +#[test] +fn legacy_redirect_drives_selects_every_drive() { + let config = parse_config_from_rdp( + &format!("{DRIVE_TEST_RDP}drivestoredirect:s:\nredirectdrives:i:1\n"), + &[], + ); + assert_eq!(config.channels().rdpdr.drives, DriveRedirection::all()); + + let config = parse_config_from_rdp(&format!("{DRIVE_TEST_RDP}drivestoredirect:s:\n"), &[]); + assert!(config.channels().rdpdr.drives.is_empty()); +} + +#[test] +fn drive_cli_flags_override_the_rdp_file() { + let rdp = format!("{DRIVE_TEST_RDP}drivestoredirect:s:C:\\\n"); + + let config = parse_config_from_rdp(&rdp, &["--drives"]); + assert_eq!(config.channels().rdpdr.drives, DriveRedirection::all()); + assert_eq!(config.properties().drives_to_redirect(), Some("*")); + + let config = parse_config_from_rdp(&rdp, &["--dynamic-drives"]); + let drives = config.channels().rdpdr.drives; + assert!(drives.redirects_dynamic()); + assert_eq!(drives.letters().collect::(), "C"); + assert_eq!(config.properties().drives_to_redirect(), Some(r"C:\;DynamicDrives")); + + let config = parse_config_from_rdp(&format!("{DRIVE_TEST_RDP}ironrdp_rdpdr:i:0\n"), &["--dynamic-drives"]); + assert!(config.channels().rdpdr.enabled); +} + +#[test] +fn named_drive_cli_options_are_collected() { + let viewer = parse_viewer_config_from_rdp( + DRIVE_TEST_RDP, + &[ + "--drive", + r"Data,D:\data,2026", + "--drive", + r"tmp,C:\Temp", + "--home-drive", + ], + ); + + let drives = viewer.named_drives(); + assert_eq!(drives.len(), 3); + assert_eq!(drives[0].name, "Data"); + assert_eq!(drives[0].path, PathBuf::from(r"D:\data,2026")); + assert_eq!(drives[1].name, "tmp"); + assert_eq!(drives[2].name, "home"); + assert!(!drives[2].path.as_os_str().is_empty()); + assert!(viewer.into_config().expect("valid config").channels().rdpdr.enabled); + + for invalid in ["Data", r",C:\", "Data,"] { + assert!( + ViewerConfig::parse_from(["ironrdp-viewer", "--drive", invalid]).is_err(), + "{invalid}" + ); + } + + // RPC hosts take drive selection from connect properties, so drive flags are rejected. + for flags in [ + &["--drives"][..], + &["--dynamic-drives"], + &["--home-drive"], + &["--drive", r"Data,C:\"], + ] { + let args = ["ironrdp-viewer", "--rpc"].into_iter().chain(flags.iter().copied()); + assert!(ViewerConfig::parse_from(args).is_err(), "{flags:?}"); + } +} + +#[cfg(windows)] +#[test] +fn named_drives_are_validated_before_connecting() { + use ironrdp_client::rdp::RdpInputSender; + use ironrdp_viewer::cli::NamedDrive; + use ironrdp_viewer::drives::ViewerDrives; + + let (input_sender, _input_receiver) = RdpInputSender::channel(1); + let windows_directory = PathBuf::from(std::env::var("SystemRoot").expect("SystemRoot is set on Windows")); + let drive = |name: &str, path: &std::path::Path| NamedDrive { + name: name.to_owned(), + path: path.to_owned(), + }; + let factory_with = |selection: DriveRedirection, drives: Vec| { + ViewerDrives::new(selection, drives).backend_factory(&input_sender) + }; + let factory = |drives: Vec| factory_with(DriveRedirection::default(), drives); + + let (valid, watcher) = factory(vec![drive("Data", &windows_directory)]).expect("valid named drive"); + assert_eq!(valid.initial_drives(), vec![(27, "Data".to_owned())]); + assert!(watcher.is_none()); + + let rejected = [ + // Names are case-insensitive. + vec![ + drive("Data", &windows_directory), + drive("data", &windows_directory.join("System32")), + ], + // A root may be redirected only once. + vec![drive("One", &windows_directory), drive("Two", &windows_directory)], + // Missing directories, files, and network shares cannot be drive roots. + vec![drive("Missing", &windows_directory.join("ironrdp-missing-directory"))], + vec![drive("File", &windows_directory.join("notepad.exe"))], + vec![drive( + "Share", + std::path::Path::new(r"\\localhost\ironrdp-missing-share"), + )], + ]; + for drives in rejected { + let names = drives.iter().map(|drive| drive.name.clone()).collect::>(); + assert!(factory(drives).is_err(), "{names:?}"); + } + + // Drive-letter names belong to logical drives once drive letters are redirected. + assert!(factory(vec![drive("C:", &windows_directory)]).is_ok()); + assert!(factory_with(DriveRedirection::parse("D"), vec![drive("C:", &windows_directory)]).is_err()); +} + +#[cfg(windows)] +#[test] +fn rdpdr_disabled_in_the_rdp_file_drops_drive_letters() { + let config = parse_config_from_rdp( + &format!("{DRIVE_TEST_RDP}ironrdp_rdpdr:i:0\ndrivestoredirect:s:*\n"), + &[], + ); + let rdpdr = &config.channels().rdpdr; + assert!(!rdpdr.enabled); + assert!(ironrdp_viewer::drives::ViewerDrives::from_rdpdr_config(rdpdr, Vec::new()).is_empty()); +} + #[test] fn audiomode_two_disables_audio_playback() { let config = parse_config_from_rdp( diff --git a/crates/ironrdp-viewer/README.md b/crates/ironrdp-viewer/README.md index bbafc59cb1..4a7a206a06 100644 --- a/crates/ironrdp-viewer/README.md +++ b/crates/ironrdp-viewer/README.md @@ -24,6 +24,16 @@ You can provide the hostname and credentials through environment variables inste RDP_HOSTNAME= RDP_USERNAME= RDP_PASSWORD= ironrdp-viewer ``` +On Windows, the viewer redirects local drives: + +- `--drives` redirects every drive, including drives connected later (`drivestoredirect:s:*`). +- `--dynamic-drives` redirects drives connected during the session, such as USB sticks (`DynamicDrives`). +- `--drive ,` redirects a directory as a drive named `NAME`; repeat it for more drives. +- `--home-drive` redirects the user profile directory as a drive named `home`. + +The `.rdp` `drivestoredirect` property selects drives with the mstsc syntax (`C:\;E:\;DynamicDrives`), and the legacy `redirectdrives:i:1` selects every drive when `drivestoredirect` is empty. +An explicit `redirectdrives:i:0` disables drive redirection even when `drivestoredirect` lists drives, and unsupported `drivestoredirect` entries (such as folder paths) are reported as warnings. + On Windows, pass `--smartcard` (or set `ironrdp_smartcard:i:1` in a `.rdp` file) to redirect local smart cards through WinSCard. RPC mode (`--rpc`) enables smartcard the same way via agent connect properties (`ironrdp_smartcard` / sandbox `SmartCardRedirection`). @@ -79,6 +89,8 @@ Currently supported properties: - `alternate shell:s:` - `shell working directory:s:` - `redirectclipboard:i:<0|1>` +- `drivestoredirect:s:` (Windows drive redirection) +- `redirectdrives:i:<0|1>` (Windows drive redirection) - `ironrdp_smartcard:i:<0|1>` (Windows WinSCard smartcard redirection) - `ironrdp_vmconnect:s:` - `ironrdp_vmconnect_basic:i:<0|1>` diff --git a/crates/ironrdp-viewer/src/cli.rs b/crates/ironrdp-viewer/src/cli.rs index 09f852d2c3..a5b0fddb9f 100644 --- a/crates/ironrdp-viewer/src/cli.rs +++ b/crates/ironrdp-viewer/src/cli.rs @@ -7,8 +7,8 @@ use anyhow::Context as _; use clap::Parser; use clap::clap_derive::ValueEnum; use ironrdp::client::config::{ - ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DvcProxyInfo, MissingField, - TransportKind, VmConnectMode, + ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DriveRedirection, DvcProxyInfo, + MissingField, TransportKind, VmConnectMode, }; use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; use ironrdp_cfg::PropertySetExt as _; @@ -283,6 +283,60 @@ struct Args { /// default. Ignored on other platforms. #[clap(long)] printer: bool, + + /// Redirect a local directory as a drive named NAME (repeatable). + /// + /// PATH may be a volume root (`E:\`) or a directory on one. Windows only. + #[clap(long = "drive", value_name = "NAME,PATH")] + drives: Vec, + + /// Redirect every local drive, including drives connected later (`drivestoredirect:s:*`). + /// + /// Windows only. + #[clap(long = "drives")] + all_drives: bool, + + /// Redirect the user profile directory as a drive named `home`. + /// + /// Windows only. + #[clap(long)] + home_drive: bool, + + /// Redirect drives connected during the session, such as USB sticks (`DynamicDrives`). + /// + /// Windows only. + #[clap(long)] + dynamic_drives: bool, +} + +/// A local directory redirected as a named drive (`--drive NAME,PATH`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NamedDrive { + /// Drive name shown in the session. + pub name: String, + /// Local directory exposed as the drive root. + pub path: PathBuf, +} + +impl core::str::FromStr for NamedDrive { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let (name, path) = s + .split_once(',') + .context("missing ',' delimiter in drive specification (expected NAME,PATH)")?; + let name = name.trim(); + if name.is_empty() || name.contains('\0') { + anyhow::bail!("drive name must be nonempty and must not contain NUL"); + } + if path.is_empty() { + anyhow::bail!("drive path must not be empty"); + } + Ok(Self { + name: name.to_owned(), + path: PathBuf::from(path), + }) + } } /// Result of parsing CLI args + loading the `.rdp` file: a configured [`ConfigBuilder`] plus the @@ -299,6 +353,7 @@ pub struct ViewerConfig { rpc: bool, rpc_endpoint: Option, redirect_printers: bool, + named_drives: Vec, } impl ViewerConfig { @@ -336,6 +391,11 @@ impl ViewerConfig { if args.rdp_file.is_some() || dump_rdp.is_some() { anyhow::bail!("--rpc cannot be combined with --rdp-file or --dump-rdp"); } + if !args.drives.is_empty() || args.all_drives || args.home_drive || args.dynamic_drives { + anyhow::bail!( + "--rpc cannot be combined with drive options; pass drivestoredirect in the connect properties" + ); + } return Ok(Self { builder: ConfigBuilder::new(), log_file, @@ -343,6 +403,7 @@ impl ViewerConfig { rpc: true, rpc_endpoint: args.rpc_endpoint, redirect_printers: false, + named_drives: Vec::new(), }); } @@ -350,6 +411,30 @@ impl ViewerConfig { // default, so the file property alone would announce the local printer without the user asking. let redirect_printers = args.printer; + if let Some(value) = properties.drives_to_redirect() { + for entry in DriveRedirection::unrecognized_entries(value) { + eprintln!("Warning: ignoring unsupported drivestoredirect entry {entry:?}"); + } + } + // `--drives` and `--dynamic-drives` override the `.rdp` drive selection; named drives + // (`--drive`, `--home-drive`) have no standard `.rdp` representation. + let drive_redirection = match (args.all_drives, args.dynamic_drives) { + (true, _) => Some(DriveRedirection::all()), + (false, true) => Some(properties.drive_redirection().unwrap_or_default().with_dynamic(true)), + (false, false) => properties.drive_redirection(), + }; + let mut named_drives = args.drives.clone(); + if args.home_drive { + let home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .context("--home-drive requires USERPROFILE or HOME")?; + named_drives.push(NamedDrive { + name: "home".to_owned(), + path: PathBuf::from(home), + }); + } + let cli_drives = args.all_drives || args.dynamic_drives || !named_drives.is_empty(); + // The library overlays everything expressible as a `.rdp` property: destination, credentials, // transport, channels, desktop size, audio, DVC proxies, etc. let builder = ConfigBuilder::from_property_set(&properties)?; @@ -372,6 +457,12 @@ impl ViewerConfig { if redirect_printers { builder = builder.with_rdpdr(true); } + if let Some(drive_redirection) = drive_redirection { + builder = builder.with_drive_redirection(drive_redirection); + } + if cli_drives { + builder = builder.with_rdpdr(true); + } Ok(Self { builder, @@ -380,6 +471,7 @@ impl ViewerConfig { rpc: false, rpc_endpoint: None, redirect_printers, + named_drives, }) } @@ -406,6 +498,11 @@ impl ViewerConfig { self.redirect_printers } + /// Local directories requested as named drives (`--drive`, `--home-drive`). + pub fn named_drives(&self) -> &[NamedDrive] { + &self.named_drives + } + /// Whether the viewer should host the local RPC server. pub fn rpc_mode(&self) -> bool { self.rpc diff --git a/crates/ironrdp-viewer/src/drives.rs b/crates/ironrdp-viewer/src/drives.rs new file mode 100644 index 0000000000..c027548615 --- /dev/null +++ b/crates/ironrdp-viewer/src/drives.rs @@ -0,0 +1,101 @@ +//! Windows drive redirection: native backend configuration and drive hotplug. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use ironrdp::client::config::{DriveRedirection, RdpdrConfig}; +use ironrdp::client::rdp::RdpInputSender; +use ironrdp_daemon::drives::{logical_drive_factory, watch_logical_drives}; +use ironrdp_rdpdr_native::{ + FIRST_NAMED_DRIVE_DEVICE_ID, LogicalDriveWatcher, RedirectedDrive, WindowsRdpdrBackendFactory, +}; + +use tracing::warn; + +use crate::cli::NamedDrive; + +/// Drives requested for one viewer session. +#[derive(Clone, Debug, Default)] +pub struct ViewerDrives { + selection: DriveRedirection, + named: Vec, +} + +impl ViewerDrives { + pub fn new(selection: DriveRedirection, named: Vec) -> Self { + Self { selection, named } + } + + /// Takes the drive selection from the session configuration, which is empty when device + /// redirection is disabled (`ironrdp_rdpdr:i:0`). + pub fn from_rdpdr_config(config: &RdpdrConfig, named: Vec) -> Self { + let selection = if config.enabled { + config.drives + } else { + DriveRedirection::default() + }; + Self::new(selection, named) + } + + /// Whether no drive is requested. + pub fn is_empty(&self) -> bool { + self.selection.is_empty() && self.named.is_empty() + } + + /// Builds the RDPDR backend factory for these drives. + /// + /// With dynamic drives, the returned watcher announces volumes connected during the session + /// through `input_sender` until it is dropped. + pub fn backend_factory( + &self, + input_sender: &RdpInputSender, + ) -> anyhow::Result<(WindowsRdpdrBackendFactory, Option)> { + let mut seen_names = BTreeSet::new(); + let mut seen_roots = BTreeSet::new(); + let named = self + .named + .iter() + .zip(FIRST_NAMED_DRIVE_DEVICE_ID..) + .map(|(drive, device_id)| { + if !seen_names.insert(drive.name.to_uppercase()) { + anyhow::bail!("drive name {} is used more than once", drive.name); + } + let path = resolve_drive_root(&drive.path)?; + if !seen_roots.insert(path.to_string_lossy().to_uppercase()) { + anyhow::bail!("drive path {} is redirected more than once", path.display()); + } + RedirectedDrive::new(device_id, &drive.name, path, false) + .with_context(|| format!("invalid drive {}", drive.name)) + }) + .collect::>>()?; + let factory = WindowsRdpdrBackendFactory::from_drives(named).context("invalid drive configuration")?; + let (factory, selection) = logical_drive_factory(Some(factory), self.selection)?; + let factory = factory.context("logical drive configuration dropped the drive factory")?; + let watcher = selection.and_then(|selection| { + watch_logical_drives(selection, input_sender.clone()) + .inspect_err(|error| warn!(%error, "Drive hotplug is unavailable; redirecting only the drives present at connect")) + .ok() + }); + Ok((factory, watcher)) + } +} + +/// Resolves a named drive path to the canonical drive-letter directory the backend opens. +/// +/// Canonicalizing resolves junctions and symbolic links in the configured path, such as a +/// redirected user profile, because the backend refuses to traverse reparse points. +fn resolve_drive_root(path: &Path) -> anyhow::Result { + let canonical = std::fs::canonicalize(path).with_context(|| format!("resolve drive path {}", path.display()))?; + if !canonical.is_dir() { + anyhow::bail!("drive path {} is not a directory", path.display()); + } + let canonical = canonical.to_string_lossy(); + match canonical.strip_prefix(r"\\?\") { + Some(root) if root.as_bytes().get(1) == Some(&b':') => Ok(PathBuf::from(root)), + _ => anyhow::bail!( + "drive path {} does not resolve to a drive-letter directory; map network shares to a drive letter", + path.display() + ), + } +} diff --git a/crates/ironrdp-viewer/src/lib.rs b/crates/ironrdp-viewer/src/lib.rs index f40d1f115c..6af40025c4 100644 --- a/crates/ironrdp-viewer/src/lib.rs +++ b/crates/ironrdp-viewer/src/lib.rs @@ -11,4 +11,6 @@ pub mod app; pub mod cli; +#[cfg(windows)] +pub mod drives; mod keymap; diff --git a/crates/ironrdp-viewer/src/main.rs b/crates/ironrdp-viewer/src/main.rs index bf115cdd66..fbf9b27f5a 100644 --- a/crates/ironrdp-viewer/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -28,7 +28,12 @@ fn main() -> anyhow::Result<()> { let dump_rdp = cli.dump_rdp().map(ToOwned::to_owned); #[cfg(windows)] let redirect_printers = cli.redirect_printers(); + let named_drives = cli.named_drives().to_vec(); let config = cli.into_config().context("configuration")?; + #[cfg(not(windows))] + if !named_drives.is_empty() || !config.channels().rdpdr.drives.is_empty() { + tracing::warn!("Ignoring drive redirection request; it is only available on Windows"); + } if let Some(dump_path) = dump_rdp { // Dump the effective, secret-stripped PropertySet observed from the built configuration. @@ -48,9 +53,11 @@ fn main() -> anyhow::Result<()> { #[cfg(windows)] let enable_smartcard = config.channels().rdpdr.smartcard; + #[cfg(windows)] + let drives = ironrdp_viewer::drives::ViewerDrives::from_rdpdr_config(&config.channels().rdpdr, named_drives); let client = RdpClient::new(config, output_event_sender); #[cfg(windows)] - let client = attach_windows_rdpdr_backend(client, enable_smartcard, redirect_printers)?; + let (client, _drive_watcher) = attach_windows_rdpdr_backend(client, enable_smartcard, redirect_printers, &drives)?; let input_event_sender = client.input_sender(); let mut app = @@ -157,26 +164,29 @@ fn run_rpc(cli: ViewerConfig) -> anyhow::Result<()> { Ok(()) } -/// Attaches the Windows RDPDR backend when smartcard or printer redirection is enabled. +/// Attaches the Windows RDPDR backend when drive, smartcard or printer redirection is enabled. /// -/// Drive redirection is not yet exposed on the viewer CLI, so the backend uses an empty drive list. /// Printer redirection announces the current default queue; when there is none, the session -/// continues without a printer. +/// continues without a printer. With dynamic drives, the returned watcher announces drives +/// connected during the session until it is dropped. #[cfg(windows)] fn attach_windows_rdpdr_backend( client: RdpClient, enable_smartcard: bool, redirect_printers: bool, -) -> anyhow::Result { - if !enable_smartcard && !redirect_printers { - return Ok(client); + drives: &ironrdp_viewer::drives::ViewerDrives, +) -> anyhow::Result<(RdpClient, Option)> { + if !enable_smartcard && !redirect_printers && drives.is_empty() { + return Ok((client, None)); } - let factory = ironrdp_rdpdr_native::WindowsRdpdrBackendFactory::from_drives(Vec::new()) - .map_err(|error| anyhow::anyhow!("invalid rdpdr configuration: {error}"))? + let (factory, drive_watcher) = drives + .backend_factory(&client.input_sender()) + .context("invalid rdpdr configuration")?; + let factory = factory .with_smartcard(enable_smartcard) .with_default_printer(redirect_printers); - Ok(client.with_rdpdr_backend_factory(Box::new(factory))) + Ok((client.with_rdpdr_backend_factory(Box::new(factory)), drive_watcher)) } fn setup_logging(log_file: Option<&str>) -> anyhow::Result<()> {