Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/ironrdp-rdpdr-native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ ironrdp-core = { path = "../ironrdp-core", version = "0.2" }
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public
ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public
ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7" } # public
nix = { version = "0.31", features = ["fs", "dir"] }
nix = { version = "0.31", features = ["fs", "dir", "feature"] }
tracing = { version = "0.1", features = ["log"] }

[target.'cfg(windows)'.dependencies]
Expand Down
3 changes: 3 additions & 0 deletions crates/ironrdp-rdpdr-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Native backend building blocks for the IronRDP RDPDR static channel.

- On macOS and Linux, the crate exports the existing `nix::backend` filesystem
backend.
The `nix::printer` backend spools PostScript jobs in an exclusively created private directory (0700), using exclusive 0600 files.
A worker thread submits each closed job to CUPS through `lp`, which gets 60 seconds to accept it, or saves it in the configured folder without replacing existing files.
A job is limited to 128 MiB and at most 16 jobs are open at once; dropping the backend deletes unfinished spool files.
- On Windows, the crate contains the native, handle-relative filesystem foundation used for drive redirection.
It validates every protocol path before resolving it below an opened volume root, and it rejects DOS device aliases and reparse-point traversal.
Its static filesystem support includes create/open, close, flush, bounded offset I/O, file and volume information, metadata changes, security descriptors, alternate data streams, directory enumeration, locks, notifications, and deny-by-default device controls.
Expand Down
2 changes: 1 addition & 1 deletion crates/ironrdp-rdpdr-native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
#[cfg(any(target_os = "macos", target_os = "linux"))]
mod nix;
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub use nix::backend;
pub use nix::{backend, printer};

#[cfg(windows)]
mod windows;
Expand Down
242 changes: 240 additions & 2 deletions crates/ironrdp-rdpdr-native/src/nix/backend.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
use std::collections::HashSet;
use std::ffi::CString;
use std::io::{Read, Seek, SeekFrom, Write};
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::MetadataExt;

use ironrdp_core::impl_as_any;
use ironrdp_pdu::{PduResult, encode_err};
use ironrdp_rdpdr::RdpdrBackend;
use ironrdp_rdpdr::pdu::RdpdrPdu;
use ironrdp_rdpdr::pdu::efs::*;
use ironrdp_rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode};
use ironrdp_rdpdr::{
RdpdrBackend, RdpdrBackendFactory, RdpdrBackendFactoryResult, RdpdrBackendProduct, RdpdrDrive, RdpdrPrinter,
};
use ironrdp_svc::SvcMessage;
use nix::dir::{Dir, OwningIter};
use tracing::{debug, warn};

use super::printer::{PrintTarget, PrinterSpooler, unsupported_response, write_response};

#[derive(Debug, Default)]
pub struct NixRdpdrBackend {
file_id: u32,
file_base: String,
file_map: std::collections::HashMap<u32, std::fs::File>,
file_path_map: std::collections::HashMap<u32, String>,
file_dir_map: std::collections::HashMap<u32, OwningIter>,
/// Print-job handling for the virtual printer, when one is announced.
printer: Option<Printer>,
}

#[derive(Debug)]
struct Printer {
device_id: u32,
spooler: PrinterSpooler,
}

impl NixRdpdrBackend {
Expand All @@ -29,14 +42,182 @@ impl NixRdpdrBackend {
..Default::default()
}
}

/// Accepts print jobs for the virtual printer announced as `device_id` and sends them to
/// `target`.
#[must_use]
pub fn with_printer(mut self, device_id: u32, target: PrintTarget) -> Self {
self.printer = Some(Printer {
device_id,
spooler: PrinterSpooler::new(target),
});
self
}
}

impl_as_any!(NixRdpdrBackend);

/// Builds a [`NixRdpdrBackend`] for every RDPDR channel lifetime.
///
/// `drives` are announced as redirected folders; `printer` announces a virtual
/// printer named after the client, whose jobs go to the given target. Drive
/// device IDs must be unique and nonzero, and the printer takes the highest ID
/// no drive uses; [`RdpdrBackendFactory::build_rdpdr_backend`] reports a
/// configuration that breaks this with a [`NixRdpdrBackendFactoryError`].
#[derive(Debug, Clone)]
pub struct NixRdpdrBackendFactory {
file_base: String,
drives: Vec<(u32, String)>,
printer: Option<(String, PrintTarget)>,
}

impl NixRdpdrBackendFactory {
pub fn new(file_base: String) -> Self {
Self {
file_base,
drives: Vec::new(),
printer: None,
}
}

#[must_use]
pub fn with_drive(mut self, device_id: u32, name: String) -> Self {
self.drives.push((device_id, name));
self
}

#[must_use]
pub fn with_printer(mut self, name: String, target: PrintTarget) -> Self {
self.printer = Some((name, target));
self
}
Comment on lines +84 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[skeptical] Factory accepts drive/printer names the Windows factory and dynamic-drive path reject — low 🟡 — with_drive and with_printer store names verbatim. Drive names are encoded as NUL-terminated UTF-16 with a truncated or DRIVE-fallback PreferredDosName, so an empty name announces an empty device and an embedded NUL truncates it server-side; the printer PrintName is NUL-terminated too. WindowsRdpdrBackendFactory rejects empty/NUL names and Rdpdr::add_dynamic_drive errors on both, leaving this new public factory as the only configuration path that lets a malformed announce through.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1625d3a. build_rdpdr_backend now returns InvalidDriveName or InvalidPrinterName for an empty name or one with an embedded NUL, like the Windows factory and Rdpdr::add_dynamic_drive. Covered by the_factory_rejects_empty_and_nul_names.

}

/// The device ID the virtual printer takes unless a drive uses it.
const DEFAULT_PRINTER_DEVICE_ID: u32 = u32::MAX - 1;

impl RdpdrBackendFactory for NixRdpdrBackendFactory {
fn build_rdpdr_backend(&self) -> RdpdrBackendFactoryResult<RdpdrBackendProduct> {
let mut device_ids = HashSet::with_capacity(self.drives.len());
for (device_id, name) in &self.drives {
if *device_id == 0 {
return Err(Box::new(NixRdpdrBackendFactoryError::ReservedDeviceId));
}
if !is_valid_device_name(name) {
return Err(Box::new(NixRdpdrBackendFactoryError::InvalidDriveName(*device_id)));
}
if !device_ids.insert(*device_id) {
return Err(Box::new(NixRdpdrBackendFactoryError::DuplicateDeviceId(*device_id)));
}
}

let mut backend = NixRdpdrBackend::new(self.file_base.clone());
let mut printer = None;
if let Some((name, target)) = &self.printer {
if !is_valid_device_name(name) {
return Err(Box::new(NixRdpdrBackendFactoryError::InvalidPrinterName));
}
let mut device_id = DEFAULT_PRINTER_DEVICE_ID;
while device_ids.contains(&device_id) {
device_id = device_id
.checked_sub(1)
.filter(|device_id| *device_id != 0)
.ok_or(NixRdpdrBackendFactoryError::NoPrinterDeviceId)?;
}
backend = backend.with_printer(device_id, target.clone());
printer = Some(RdpdrPrinter::new(
device_id,
name.clone(),
DEFAULT_PRINTER_DRIVER_NAME.to_owned(),
));
}

let drives = self
.drives
.iter()
.map(|(id, name)| RdpdrDrive::new(*id, name.clone()))
.collect();
let mut product = RdpdrBackendProduct::new(Box::new(backend), drives);
if let Some(printer) = printer {
product = product.with_printer(printer);
}
Ok(product)
}
}
Comment on lines +60 to +146

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[skeptical] NixRdpdrBackendFactory performs no device-ID validation — medium 🟠 — with_drive accepts duplicate ids, 0, and ids colliding with the hardcoded PRINTER_DEVICE_ID. Duplicate announced ids make Rdpdr::announce_devices fail mid-connection ('device was announced more than once before server acknowledgement'), aborting the channel for what is a configuration error. The Windows sibling factory rejects duplicate ids up front and walks to a collision-free printer id; direct consumers of this exported factory bypass the client builder's collision checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 23b3805. build_rdpdr_backend now returns a NixRdpdrBackendFactoryError for drive ID 0 or a duplicate drive ID, before the channel starts. The printer no longer uses a fixed ID. It takes the highest device ID that no drive uses, starting at u32::MAX - 1 as in the Windows factory. Covered by the_factory_rejects_reserved_and_duplicate_drive_ids and the_printer_takes_an_id_no_drive_uses.


/// Device names are announced as NUL-terminated strings, so an empty name or one with an embedded
/// NUL would reach the server empty or cut short.
fn is_valid_device_name(name: &str) -> bool {
!name.is_empty() && !name.contains('\0')
}

/// Invalid [`NixRdpdrBackendFactory`] configuration.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NixRdpdrBackendFactoryError {
/// A drive used device ID 0.
ReservedDeviceId,
/// More than one drive used the same device ID.
DuplicateDeviceId(u32),
/// The drives use every device ID the printer could take.
NoPrinterDeviceId,
/// The drive with this device ID has an empty name or one with an embedded NUL.
InvalidDriveName(u32),
/// The printer has an empty name or one with an embedded NUL.
InvalidPrinterName,
}

impl core::fmt::Display for NixRdpdrBackendFactoryError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::ReservedDeviceId => f.write_str("RDPDR device ID 0 is reserved"),
Self::DuplicateDeviceId(device_id) => write!(f, "duplicate RDPDR device ID {device_id}"),
Self::NoPrinterDeviceId => f.write_str("no RDPDR device ID is available for the printer"),
Self::InvalidDriveName(device_id) => {
write!(
f,
"RDPDR drive {device_id} has an empty name or one with an embedded NUL"
)
}
Self::InvalidPrinterName => f.write_str("the RDPDR printer has an empty name or one with an embedded NUL"),
}
}
}

impl core::error::Error for NixRdpdrBackendFactoryError {}

impl RdpdrBackend for NixRdpdrBackend {
fn handle_server_device_announce_response(&mut self, _pdu: ServerDeviceAnnounceResponse) -> PduResult<()> {
fn reset(&mut self) -> PduResult<()> {
if let Some(printer) = self.printer.as_mut() {
printer.spooler.reset();
}
Ok(())
}
fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()> {
if self
.printer
.as_ref()
.is_some_and(|printer| printer.device_id == pdu.device_id)
{
if pdu.result_code == NtStatus::SUCCESS {
tracing::info!("The server accepted the redirected printer");
} else {
warn!(?pdu.result_code, "The server rejected the redirected printer");
}
}
Ok(())
}
fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> {
match self.printer.as_mut() {
Some(printer) => printer.spooler.handle(req),
None => Ok(vec![SvcMessage::from(unsupported_response(req))]),
}
}
Comment on lines +209 to +214

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[protocol] No-printer fallback completes printer IRPs with a close-response PDU — low 🟡 — When no spooler is configured, every printer IRP — including IRP_MJ_WRITE — is answered with a DeviceCloseResponse, which is not the DR_PRN_WRITE_RSP shape a server expects for a write completion, so the Length parse fails or truncates. Unreachable through NixRdpdrBackendFactory (which configures the product printer and the backend spooler together) but visible when integrations assemble the backend manually; it also mirrors the trait's default handler.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 23b3805. Without a printer, a create, write or close is answered with its own response type and NOT_SUPPORTED, and an oversized write gets a write response instead of an error. Covered by unsupported_requests_are_answered_with_their_own_response_type. The default RdpdrBackend::handle_printer_io_request answers every request with a close response too. I left that alone because it is outside this PR.

fn reject_printer_write(&mut self, req: DeviceIoRequest) -> PduResult<Vec<SvcMessage>> {
match self.printer.as_mut() {
Some(printer) => printer.spooler.reject_write(req),
None => Ok(vec![SvcMessage::from(write_response(req, 0, NtStatus::NOT_SUPPORTED))]),
}
}
fn handle_scard_call(
&mut self,
_req: DeviceControlRequest<ScardIoCtlCode>,
Expand Down Expand Up @@ -801,3 +982,60 @@ pub(crate) fn process_dependent_file(
Some(file) => fx(file, request),
}
}

#[cfg(test)]
mod tests {
use super::*;

fn build_error(factory: &NixRdpdrBackendFactory) -> NixRdpdrBackendFactoryError {
let Err(error) = factory.build_rdpdr_backend() else {
panic!("the factory accepted an invalid configuration");
};
*error
.downcast_ref::<NixRdpdrBackendFactoryError>()
.expect("a factory configuration error")
}

#[test]
fn the_factory_rejects_reserved_and_duplicate_drive_ids() {
let factory = NixRdpdrBackendFactory::new("/tmp".to_owned());
assert_eq!(
build_error(&factory.clone().with_drive(0, "zero".to_owned())),
NixRdpdrBackendFactoryError::ReservedDeviceId
);
assert_eq!(
build_error(&factory.with_drive(1, "a".to_owned()).with_drive(1, "b".to_owned())),
NixRdpdrBackendFactoryError::DuplicateDeviceId(1)
);
}

#[test]
fn the_factory_rejects_empty_and_nul_names() {
let factory = NixRdpdrBackendFactory::new("/tmp".to_owned());
assert_eq!(
build_error(&factory.clone().with_drive(1, String::new())),
NixRdpdrBackendFactoryError::InvalidDriveName(1)
);
assert_eq!(
build_error(&factory.clone().with_drive(2, "home\0x".to_owned())),
NixRdpdrBackendFactoryError::InvalidDriveName(2)
);
assert_eq!(
build_error(&factory.with_printer(String::new(), PrintTarget::DefaultPrinter)),
NixRdpdrBackendFactoryError::InvalidPrinterName
);
}

#[test]
fn the_printer_takes_an_id_no_drive_uses() {
let product = NixRdpdrBackendFactory::new("/tmp".to_owned())
.with_drive(DEFAULT_PRINTER_DEVICE_ID, "taken".to_owned())
.with_printer("printer".to_owned(), PrintTarget::DefaultPrinter)
.build_rdpdr_backend()
.expect("valid configuration");
assert_eq!(
product.printer().expect("printer").device_id(),
DEFAULT_PRINTER_DEVICE_ID - 1
);
}
}
1 change: 1 addition & 0 deletions crates/ironrdp-rdpdr-native/src/nix/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod backend;
pub mod printer;
Loading
Loading