From 4afbb5ee9ca5aea684433091a4d940ade54ed6d1 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sat, 26 Sep 2026 00:08:07 -0600 Subject: [PATCH 1/3] feat(rdpdr-native): redirect a printer on Linux and macOS The RDPDR printer support added for Windows has no counterpart in the `nix` backend, so Linux and macOS clients cannot offer a printer. - `NixRdpdrBackendFactory` builds a `NixRdpdrBackend` per channel and announces redirected drives and, optionally, one virtual printer that uses the default PostScript driver. - `nix::printer::PrinterSpooler` handles the printer's create, write and close requests. A job is spooled while the server streams it, then handed to CUPS with `lp` (the default or a named destination), or saved as a PostScript file in a folder. When `lp` is missing or refuses the job, the file is kept in the user's Downloads folder (or home) instead. - Spooling is private: jobs go into a directory created with `mkdtemp` (0700) as files created exclusively with mode 0600, saved files never replace or follow an existing path, a job abandoned after an oversized write is discarded on close, and dropping the backend deletes unfinished jobs. - `PrintTarget::parse` reads a target from a string: `default`, `folder:`, or a CUPS destination name. --- crates/ironrdp-rdpdr-native/Cargo.toml | 2 +- crates/ironrdp-rdpdr-native/README.md | 2 + crates/ironrdp-rdpdr-native/src/lib.rs | 2 +- .../ironrdp-rdpdr-native/src/nix/backend.rs | 99 +++- crates/ironrdp-rdpdr-native/src/nix/mod.rs | 1 + .../ironrdp-rdpdr-native/src/nix/printer.rs | 443 ++++++++++++++++++ 6 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 crates/ironrdp-rdpdr-native/src/nix/printer.rs diff --git a/crates/ironrdp-rdpdr-native/Cargo.toml b/crates/ironrdp-rdpdr-native/Cargo.toml index 79c7fb3be6..17107a04d1 100644 --- a/crates/ironrdp-rdpdr-native/Cargo.toml +++ b/crates/ironrdp-rdpdr-native/Cargo.toml @@ -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] diff --git a/crates/ironrdp-rdpdr-native/README.md b/crates/ironrdp-rdpdr-native/README.md index 55e161b3af..d97a52f890 100644 --- a/crates/ironrdp-rdpdr-native/README.md +++ b/crates/ironrdp-rdpdr-native/README.md @@ -4,6 +4,8 @@ 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. + Closing a job submits it to CUPS or saves it in the configured folder without replacing existing files; 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. diff --git a/crates/ironrdp-rdpdr-native/src/lib.rs b/crates/ironrdp-rdpdr-native/src/lib.rs index 6b7e735fc7..700e371259 100644 --- a/crates/ironrdp-rdpdr-native/src/lib.rs +++ b/crates/ironrdp-rdpdr-native/src/lib.rs @@ -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; diff --git a/crates/ironrdp-rdpdr-native/src/nix/backend.rs b/crates/ironrdp-rdpdr-native/src/nix/backend.rs index 34ffa81e96..38ea890163 100644 --- a/crates/ironrdp-rdpdr-native/src/nix/backend.rs +++ b/crates/ironrdp-rdpdr-native/src/nix/backend.rs @@ -5,14 +5,18 @@ 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}; + #[derive(Debug, Default)] pub struct NixRdpdrBackend { file_id: u32, @@ -20,6 +24,8 @@ pub struct NixRdpdrBackend { file_map: std::collections::HashMap, file_path_map: std::collections::HashMap, file_dir_map: std::collections::HashMap, + /// Print-job handling for the virtual printer, when one is announced. + printer: Option, } impl NixRdpdrBackend { @@ -29,14 +35,103 @@ impl NixRdpdrBackend { ..Default::default() } } + + /// Accepts print jobs for the announced virtual printer and sends them to `target`. + #[must_use] + pub fn with_printer(mut self, target: PrintTarget) -> Self { + self.printer = Some(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. +#[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 + } +} + +/// Device id of the virtual printer; drives use the ids given to [`NixRdpdrBackendFactory::with_drive`]. +pub const PRINTER_DEVICE_ID: u32 = 0x0001_0000; + +impl RdpdrBackendFactory for NixRdpdrBackendFactory { + fn build_rdpdr_backend(&self) -> RdpdrBackendFactoryResult { + let mut backend = NixRdpdrBackend::new(self.file_base.clone()); + if let Some((_, target)) = &self.printer { + backend = backend.with_printer(target.clone()); + } + 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((name, _)) = &self.printer { + product = product.with_printer(RdpdrPrinter::new( + PRINTER_DEVICE_ID, + name.clone(), + DEFAULT_PRINTER_DRIVER_NAME.to_owned(), + )); + } + Ok(product) + } +} + impl RdpdrBackend for NixRdpdrBackend { - fn handle_server_device_announce_response(&mut self, _pdu: ServerDeviceAnnounceResponse) -> PduResult<()> { + fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()> { + if pdu.device_id == PRINTER_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> { + match self.printer.as_mut() { + Some(spooler) => spooler.handle(req), + None => Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse( + DeviceCloseResponse { + device_io_response: DeviceIoResponse::new(req.into_device_io_request(), NtStatus::NOT_SUPPORTED), + }, + ))]), + } + } + fn reject_printer_write(&mut self, req: DeviceIoRequest) -> PduResult> { + match self.printer.as_mut() { + Some(spooler) => spooler.reject_write(req), + None => Err(ironrdp_pdu::pdu_other_err!("no printer announced")), + } + } fn handle_scard_call( &mut self, _req: DeviceControlRequest, diff --git a/crates/ironrdp-rdpdr-native/src/nix/mod.rs b/crates/ironrdp-rdpdr-native/src/nix/mod.rs index fceb14190a..3517f70128 100644 --- a/crates/ironrdp-rdpdr-native/src/nix/mod.rs +++ b/crates/ironrdp-rdpdr-native/src/nix/mod.rs @@ -1 +1,2 @@ pub mod backend; +pub mod printer; diff --git a/crates/ironrdp-rdpdr-native/src/nix/printer.rs b/crates/ironrdp-rdpdr-native/src/nix/printer.rs new file mode 100644 index 0000000000..bb5ae43747 --- /dev/null +++ b/crates/ironrdp-rdpdr-native/src/nix/printer.rs @@ -0,0 +1,443 @@ +//! Printer redirection for Linux and macOS: the remote's print jobs land in +//! the local print system. +//! +//! The channel announces one virtual printer to the server (MS-RDPEPC). When +//! the user prints to it, the server-side PostScript driver renders the job and +//! pushes the bytes down as a create / write... / close sequence on that +//! device. The job is spooled to a temporary file while it streams; on close +//! it is handed to `lp`, or written to a file in the user's downloads folder +//! when there is no printer to hand it to. + +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::Write as _; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use ironrdp_pdu::PduResult; +use ironrdp_rdpdr::pdu::RdpdrPdu; +use ironrdp_rdpdr::pdu::efs::{ + DeviceCloseResponse, DeviceCreateResponse, DeviceIoRequest, DeviceIoResponse, DeviceWriteResponse, Information, + NtStatus, PrinterIoRequest, +}; +use ironrdp_svc::SvcMessage; +use tracing::{debug, info, warn}; + +/// Where a finished job goes. +#[derive(Debug, Clone)] +pub enum PrintTarget { + /// The CUPS default destination (`lp` with no `-d`). + DefaultPrinter, + /// A named CUPS destination. + Printer(String), + /// A directory: each job becomes `RDP print .ps` inside it. + Folder(PathBuf), +} + +/// One job in flight: the server opened the printer and is writing. +#[derive(Debug)] +struct Job { + spool: PathBuf, + file: std::fs::File, + bytes: u64, +} + +/// Print-job state for one virtual printer. +#[derive(Debug)] +pub struct PrinterSpooler { + target: PrintTarget, + /// Fallback when `lp` is missing or refuses the job. + fallback_dir: PathBuf, + /// Private, atomically created 0700 directory, allocated on the first print job. + spool_dir: Option, + next_file_id: u32, + jobs: HashMap, + /// Handles whose job was abandoned after an oversized write; a later close + /// must not submit whatever was spooled before. + poisoned: HashMap, +} + +impl PrinterSpooler { + pub fn new(target: PrintTarget) -> Self { + Self { + target, + fallback_dir: default_fallback_dir(), + spool_dir: None, + next_file_id: 1, + jobs: HashMap::new(), + poisoned: HashMap::new(), + } + } + + #[cfg(test)] + fn with_fallback_dir(mut self, dir: PathBuf) -> Self { + self.fallback_dir = dir; + self + } + + pub fn handle(&mut self, req: PrinterIoRequest) -> PduResult> { + match req { + PrinterIoRequest::Create(create) => { + let file_id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + let response = match self.open_spool(file_id) { + Ok(job) => { + debug!(file_id, spool = ?job.spool, "Print job opened"); + self.jobs.insert(file_id, job); + DeviceCreateResponse { + device_io_reply: DeviceIoResponse::new(create.device_io_request, NtStatus::SUCCESS), + file_id, + information: Information::FILE_OPENED, + } + } + Err(error) => { + warn!(%error, "Could not open a spool file for a print job"); + DeviceCreateResponse { + device_io_reply: DeviceIoResponse::new(create.device_io_request, NtStatus::UNSUCCESSFUL), + file_id: 0, + information: Information::FILE_OPENED, + } + } + }; + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCreateResponse(response))]) + } + PrinterIoRequest::Write(write) => { + let file_id = write.device_io_request.file_id; + let length = u32::try_from(write.write_data.len()).unwrap_or(u32::MAX); + let status = match self.jobs.get_mut(&file_id) { + Some(job) => match job.file.write_all(&write.write_data) { + Ok(()) => { + job.bytes = job.bytes.saturating_add(u64::from(length)); + NtStatus::SUCCESS + } + Err(error) => { + warn!(%error, file_id, "Could not spool print data"); + NtStatus::UNSUCCESSFUL + } + }, + None => NtStatus::UNSUCCESSFUL, + }; + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceWriteResponse( + DeviceWriteResponse { + device_io_reply: DeviceIoResponse::new(write.device_io_request, status), + length, + }, + ))]) + } + PrinterIoRequest::Close(close) => { + let file_id = close.device_io_request.file_id; + if let Some(spool) = self.poisoned.remove(&file_id) { + let _ = std::fs::remove_file(spool); + } else if let Some(job) = self.jobs.remove(&file_id) { + drop(job.file); + self.submit(&job.spool, job.bytes); + } + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse( + DeviceCloseResponse { + device_io_response: DeviceIoResponse::new(close.device_io_request, NtStatus::SUCCESS), + }, + ))]) + } + } + } + + fn open_spool(&mut self, file_id: u32) -> std::io::Result { + if self.spool_dir.is_none() { + // mkdtemp creates the directory exclusively with mode 0700; no shared + // pathname can be substituted between creation and opening a job. + let template = std::env::temp_dir().join("ironrdp-print-XXXXXX"); + self.spool_dir = Some(nix::unistd::mkdtemp(&template)?); + } + let dir = self + .spool_dir + .as_ref() + .ok_or_else(|| std::io::Error::other("missing spool directory"))?; + let spool = dir.join(format!("{file_id}.ps")); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&spool)?; + Ok(Job { spool, file, bytes: 0 }) + } + + /// Abandons the job behind an oversized write so a later close discards it. + pub fn reject_write(&mut self, req: DeviceIoRequest) -> PduResult> { + if let Some(job) = self.jobs.remove(&req.file_id) { + warn!( + file_id = req.file_id, + "Print job abandoned: the server sent an oversized write" + ); + self.poisoned.insert(req.file_id, job.spool); + } + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceWriteResponse( + DeviceWriteResponse { + device_io_reply: DeviceIoResponse::new(req, NtStatus::UNSUCCESSFUL), + length: 0, + }, + ))]) + } + + fn submit(&self, spool: &Path, bytes: u64) { + if bytes == 0 { + debug!(?spool, "Empty print job discarded"); + let _ = std::fs::remove_file(spool); + return; + } + let mut command = Command::new("lp"); + match &self.target { + PrintTarget::DefaultPrinter => {} + PrintTarget::Printer(name) => { + command.arg("-d").arg(name); + } + PrintTarget::Folder(dir) => { + self.keep(spool, dir); + return; + } + } + command.arg("-t").arg("RDP print job").arg(spool); + match command.output() { + Ok(output) if output.status.success() => { + info!( + bytes, + "Print job handed to lp: {}", + String::from_utf8_lossy(&output.stdout).trim() + ); + let _ = std::fs::remove_file(spool); + } + Ok(output) => { + warn!( + "lp refused the print job ({}); keeping it as a file instead", + String::from_utf8_lossy(&output.stderr).trim() + ); + self.keep(spool, &self.fallback_dir.clone()); + } + Err(error) => { + warn!(%error, "lp is not available; keeping the print job as a file instead"); + self.keep(spool, &self.fallback_dir.clone()); + } + } + } + + /// Moves the spooled job into `dir` under a readable name. + fn keep(&self, spool: &Path, dir: &Path) { + if let Err(error) = std::fs::create_dir_all(dir) { + warn!(%error, ?dir, "Could not create the print output folder"); + return; + } + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // The destination may also be shared. Exclusive creation avoids overwriting + // existing documents or following a symlink, including across filesystems. + let mut n = 0u64; + let (destination, mut output) = loop { + let name = if n == 0 { + format!("RDP print {stamp}.ps") + } else { + format!("RDP print {stamp} ({n}).ps") + }; + let destination = dir.join(name); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&destination) + { + Ok(file) => break (destination, file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => n = n.saturating_add(1), + Err(error) => { + warn!(%error, ?destination, "Could not create the print output file"); + return; + } + } + }; + let moved = File::open(spool) + .and_then(|mut input| std::io::copy(&mut input, &mut output)) + .and_then(|_| output.flush()) + .and_then(|()| std::fs::remove_file(spool)); + match moved { + Ok(()) => info!(?destination, "Print job saved as a PostScript file"), + Err(error) => { + drop(output); + let _ = std::fs::remove_file(&destination); + warn!(%error, ?destination, "Could not save the print job"); + } + } + } +} + +impl Drop for PrinterSpooler { + fn drop(&mut self) { + // A disconnected session abandons both open and rejected jobs. + self.jobs.clear(); + if let Some(dir) = self.spool_dir.take() { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +fn default_fallback_dir() -> PathBuf { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + let downloads = home.join("Downloads"); + if downloads.is_dir() { downloads } else { home } +} + +impl PrintTarget { + /// Parses a target description: `default` (or an empty string), `folder:`, or a CUPS + /// destination name. + pub fn parse(value: &str) -> Self { + let value = value.trim(); + if value.is_empty() || value.eq_ignore_ascii_case("default") { + Self::DefaultPrinter + } else if let Some(dir) = value.strip_prefix("folder:") { + Self::Folder(PathBuf::from(dir)) + } else { + Self::Printer(value.to_owned()) + } + } +} + +#[cfg(test)] +mod tests { + use ironrdp_rdpdr::pdu::efs::{ + CreateDisposition, CreateOptions, DesiredAccess, DeviceCloseRequest, DeviceCreateRequest, DeviceWriteRequest, + FileAttributes, MajorFunction, MinorFunction, SharedAccess, + }; + use std::os::unix::fs::{PermissionsExt as _, symlink}; + + use super::*; + + fn io(file_id: u32, major: MajorFunction) -> DeviceIoRequest { + DeviceIoRequest { + device_id: 7, + file_id, + completion_id: 1, + major_function: major, + minor_function: MinorFunction::IRP_MN_QUERY_DIRECTORY, + } + } + + #[test] + fn a_job_streams_into_the_folder_target() { + let dir = std::env::temp_dir().join(format!("ironrdp-print-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut spooler = PrinterSpooler::new(PrintTarget::Folder(dir.clone())).with_fallback_dir(dir.clone()); + + let create = spooler + .handle(PrinterIoRequest::Create(DeviceCreateRequest { + device_io_request: io(0, MajorFunction::Create), + desired_access: DesiredAccess::empty(), + allocation_size: 0, + file_attributes: FileAttributes::empty(), + shared_access: SharedAccess::empty(), + create_disposition: CreateDisposition::FILE_OPEN, + create_options: CreateOptions::empty(), + path: String::new(), + })) + .expect("create"); + assert_eq!(create.len(), 1); + let file_id = spooler.jobs.keys().copied().next().expect("one open job"); + + for chunk in [&b"%!PS-Adobe-3.0\n"[..], b"showpage\n"] { + spooler + .handle(PrinterIoRequest::Write(DeviceWriteRequest { + device_io_request: io(file_id, MajorFunction::Write), + offset: 0, + write_data: chunk.to_vec(), + })) + .expect("write"); + } + spooler + .handle(PrinterIoRequest::Close(DeviceCloseRequest::decode(io( + file_id, + MajorFunction::Close, + )))) + .expect("close"); + + let saved: Vec<_> = std::fs::read_dir(&dir).expect("dir").flatten().collect(); + assert_eq!(saved.len(), 1, "one job file"); + assert_eq!( + saved[0].metadata().expect("saved job").permissions().mode() & 0o777, + 0o600 + ); + let content = std::fs::read_to_string(saved[0].path()).expect("content"); + assert_eq!(content, "%!PS-Adobe-3.0\nshowpage\n"); + assert!(spooler.jobs.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_rejected_write_poisons_the_job_so_close_discards_it() { + let dir = std::env::temp_dir().join(format!("ironrdp-print-test-poison-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut spooler = PrinterSpooler::new(PrintTarget::Folder(dir.clone())).with_fallback_dir(dir.clone()); + spooler + .handle(PrinterIoRequest::Create(DeviceCreateRequest { + device_io_request: io(0, MajorFunction::Create), + desired_access: DesiredAccess::empty(), + allocation_size: 0, + file_attributes: FileAttributes::empty(), + shared_access: SharedAccess::empty(), + create_disposition: CreateDisposition::FILE_OPEN, + create_options: CreateOptions::empty(), + path: String::new(), + })) + .expect("create"); + let file_id = spooler.jobs.keys().copied().next().expect("one open job"); + spooler.reject_write(io(file_id, MajorFunction::Write)).expect("reject"); + spooler + .handle(PrinterIoRequest::Close(DeviceCloseRequest::decode(io( + file_id, + MajorFunction::Close, + )))) + .expect("close"); + assert!( + !dir.exists() || std::fs::read_dir(&dir).expect("dir").next().is_none(), + "nothing saved" + ); + } + + #[test] + fn targets_parse_from_a_description() { + assert!(matches!(PrintTarget::parse("default"), PrintTarget::DefaultPrinter)); + assert!(matches!(PrintTarget::parse(""), PrintTarget::DefaultPrinter)); + assert!(matches!(PrintTarget::parse("HP_LaserJet"), PrintTarget::Printer(n) if n == "HP_LaserJet")); + assert!(matches!(PrintTarget::parse("folder:/tmp/out"), PrintTarget::Folder(p) if p == Path::new("/tmp/out"))); + } + + #[test] + fn spool_files_are_private_and_removed_when_the_session_ends() { + let mut spooler = PrinterSpooler::new(PrintTarget::DefaultPrinter); + let mut job = spooler.open_spool(1).expect("private job"); + let dir = job.spool.parent().expect("spool directory").to_path_buf(); + assert_eq!( + std::fs::metadata(&dir).expect("directory").permissions().mode() & 0o777, + 0o700 + ); + assert_eq!(job.file.metadata().expect("job").permissions().mode() & 0o777, 0o600); + job.file.write_all(b"private document").expect("write"); + spooler.jobs.insert(1, job); + drop(spooler); + assert!(!dir.exists(), "disconnection removes unfinished documents"); + } + + #[test] + fn an_existing_spool_symlink_is_never_followed() { + let mut spooler = PrinterSpooler::new(PrintTarget::DefaultPrinter); + let job = spooler.open_spool(1).expect("allocate directory"); + let dir = job.spool.parent().expect("directory"); + let target = dir.join("existing-document"); + std::fs::write(&target, b"keep this").expect("target"); + symlink(&target, dir.join("2.ps")).expect("symlink"); + assert_eq!( + spooler.open_spool(2).expect_err("collision must fail").kind(), + std::io::ErrorKind::AlreadyExists + ); + assert_eq!(std::fs::read(&target).expect("unchanged target"), b"keep this"); + } +} From 23b3805b29518b81fd9e2e36f7bc42ee8c7b7e10 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 27 Sep 2026 23:23:51 -0600 Subject: [PATCH 2/3] fix(rdpdr): submit print jobs off the channel thread Address the review of the Linux and macOS printer backend: - A closed job is queued for a worker thread, which hands it to `lp` or saves it in the configured folder. `lp` gets 60 seconds to accept the job before it is killed, so a hung print system no longer stalls the static channels. - A job `lp` cannot take is discarded instead of being saved in the user's home folder, so only an explicit folder target writes files. - A job is limited to 128 MiB and at most 16 jobs are open at once, as in the Windows backend. A job that fails to spool is abandoned rather than submitted in part. - A failed write reports a length of 0 (MS-RDPEPC 3.2.5.1.12), and a failed create carries no file handle and no FILE_OPENED. - Without a printer, each request is answered with its own response type instead of a close response. - The factory rejects drive ID 0 and duplicate drive IDs, and the printer takes the highest device ID no drive uses. - `reset` discards open jobs and their spool files. - A saved job is kept when removing its spool file fails. - Remove the unused `PrintTarget::parse`. --- crates/ironrdp-rdpdr-native/README.md | 3 +- .../ironrdp-rdpdr-native/src/nix/backend.rs | 153 +++- .../ironrdp-rdpdr-native/src/nix/printer.rs | 651 +++++++++++------- 3 files changed, 537 insertions(+), 270 deletions(-) diff --git a/crates/ironrdp-rdpdr-native/README.md b/crates/ironrdp-rdpdr-native/README.md index d97a52f890..7d3fc7fcdc 100644 --- a/crates/ironrdp-rdpdr-native/README.md +++ b/crates/ironrdp-rdpdr-native/README.md @@ -5,7 +5,8 @@ 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. - Closing a job submits it to CUPS or saves it in the configured folder without replacing existing files; dropping the backend deletes unfinished spool 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. diff --git a/crates/ironrdp-rdpdr-native/src/nix/backend.rs b/crates/ironrdp-rdpdr-native/src/nix/backend.rs index 38ea890163..f000079ae6 100644 --- a/crates/ironrdp-rdpdr-native/src/nix/backend.rs +++ b/crates/ironrdp-rdpdr-native/src/nix/backend.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::ffi::CString; use std::io::{Read, Seek, SeekFrom, Write}; use std::os::fd::{AsFd, AsRawFd}; @@ -15,7 +16,7 @@ use ironrdp_svc::SvcMessage; use nix::dir::{Dir, OwningIter}; use tracing::{debug, warn}; -use super::printer::{PrintTarget, PrinterSpooler}; +use super::printer::{PrintTarget, PrinterSpooler, unsupported_response, write_response}; #[derive(Debug, Default)] pub struct NixRdpdrBackend { @@ -25,7 +26,13 @@ pub struct NixRdpdrBackend { file_path_map: std::collections::HashMap, file_dir_map: std::collections::HashMap, /// Print-job handling for the virtual printer, when one is announced. - printer: Option, + printer: Option, +} + +#[derive(Debug)] +struct Printer { + device_id: u32, + spooler: PrinterSpooler, } impl NixRdpdrBackend { @@ -36,10 +43,14 @@ impl NixRdpdrBackend { } } - /// Accepts print jobs for the announced virtual printer and sends them to `target`. + /// Accepts print jobs for the virtual printer announced as `device_id` and sends them to + /// `target`. #[must_use] - pub fn with_printer(mut self, target: PrintTarget) -> Self { - self.printer = Some(PrinterSpooler::new(target)); + pub fn with_printer(mut self, device_id: u32, target: PrintTarget) -> Self { + self.printer = Some(Printer { + device_id, + spooler: PrinterSpooler::new(target), + }); self } } @@ -49,7 +60,10 @@ 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. +/// 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, @@ -79,35 +93,88 @@ impl NixRdpdrBackendFactory { } } -/// Device id of the virtual printer; drives use the ids given to [`NixRdpdrBackendFactory::with_drive`]. -pub const PRINTER_DEVICE_ID: u32 = 0x0001_0000; +/// 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 { + let mut device_ids = HashSet::with_capacity(self.drives.len()); + for (device_id, _) in &self.drives { + if *device_id == 0 { + return Err(Box::new(NixRdpdrBackendFactoryError::ReservedDeviceId)); + } + if !device_ids.insert(*device_id) { + return Err(Box::new(NixRdpdrBackendFactoryError::DuplicateDeviceId(*device_id))); + } + } + let mut backend = NixRdpdrBackend::new(self.file_base.clone()); - if let Some((_, target)) = &self.printer { - backend = backend.with_printer(target.clone()); + let mut printer = None; + if let Some((name, target)) = &self.printer { + 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((name, _)) = &self.printer { - product = product.with_printer(RdpdrPrinter::new( - PRINTER_DEVICE_ID, - name.clone(), - DEFAULT_PRINTER_DRIVER_NAME.to_owned(), - )); + if let Some(printer) = printer { + product = product.with_printer(printer); } Ok(product) } } +/// 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, +} + +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"), + } + } +} + +impl core::error::Error for NixRdpdrBackendFactoryError {} + impl RdpdrBackend for NixRdpdrBackend { + 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 pdu.device_id == PRINTER_DEVICE_ID { + 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 { @@ -118,18 +185,14 @@ impl RdpdrBackend for NixRdpdrBackend { } fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult> { match self.printer.as_mut() { - Some(spooler) => spooler.handle(req), - None => Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse( - DeviceCloseResponse { - device_io_response: DeviceIoResponse::new(req.into_device_io_request(), NtStatus::NOT_SUPPORTED), - }, - ))]), + Some(printer) => printer.spooler.handle(req), + None => Ok(vec![SvcMessage::from(unsupported_response(req))]), } } fn reject_printer_write(&mut self, req: DeviceIoRequest) -> PduResult> { match self.printer.as_mut() { - Some(spooler) => spooler.reject_write(req), - None => Err(ironrdp_pdu::pdu_other_err!("no printer announced")), + Some(printer) => printer.spooler.reject_write(req), + None => Ok(vec![SvcMessage::from(write_response(req, 0, NtStatus::NOT_SUPPORTED))]), } } fn handle_scard_call( @@ -896,3 +959,43 @@ 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::() + .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_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 + ); + } +} diff --git a/crates/ironrdp-rdpdr-native/src/nix/printer.rs b/crates/ironrdp-rdpdr-native/src/nix/printer.rs index bb5ae43747..e255fc83ae 100644 --- a/crates/ironrdp-rdpdr-native/src/nix/printer.rs +++ b/crates/ironrdp-rdpdr-native/src/nix/printer.rs @@ -4,16 +4,19 @@ //! The channel announces one virtual printer to the server (MS-RDPEPC). When //! the user prints to it, the server-side PostScript driver renders the job and //! pushes the bytes down as a create / write... / close sequence on that -//! device. The job is spooled to a temporary file while it streams; on close -//! it is handed to `lp`, or written to a file in the user's downloads folder -//! when there is no printer to hand it to. +//! device. The job is spooled to a temporary file while it streams. On close, +//! a worker thread hands it to `lp` or saves it in the configured folder, so a +//! slow print system never stalls the channel. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; -use std::io::Write as _; +use std::io::{Read as _, Write as _}; use std::os::unix::fs::OpenOptionsExt as _; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; use ironrdp_pdu::PduResult; use ironrdp_rdpdr::pdu::RdpdrPdu; @@ -24,6 +27,16 @@ use ironrdp_rdpdr::pdu::efs::{ use ironrdp_svc::SvcMessage; use tracing::{debug, info, warn}; +/// Most print jobs the printer keeps open at once, counting abandoned jobs that +/// still wait for their close. +const MAX_OPEN_PRINT_JOBS: usize = 16; +/// Largest print job the printer accepts, in bytes. +const MAX_PRINT_JOB_BYTES: u64 = 128 * 1024 * 1024; +/// Closed jobs that may wait for the submission thread. +const SUBMISSION_QUEUE_CAPACITY: usize = 16; +/// How long `lp` may take to accept a job before it is killed. +const LP_TIMEOUT: Duration = Duration::from_secs(60); + /// Where a finished job goes. #[derive(Debug, Clone)] pub enum PrintTarget { @@ -39,107 +52,180 @@ pub enum PrintTarget { #[derive(Debug)] struct Job { spool: PathBuf, - file: std::fs::File, + file: File, + bytes: u64, +} + +/// A closed job waiting for the submission thread. +#[derive(Debug)] +struct FinishedJob { + spool: PathBuf, bytes: u64, } +/// The thread that submits closed jobs, one at a time. +#[derive(Debug)] +struct Submitter { + jobs: SyncSender, + #[cfg_attr( + not(test), + expect(dead_code, reason = "tests join the thread; a session leaves it to finish the queue") + )] + thread: JoinHandle<()>, +} + /// Print-job state for one virtual printer. #[derive(Debug)] pub struct PrinterSpooler { target: PrintTarget, - /// Fallback when `lp` is missing or refuses the job. - fallback_dir: PathBuf, /// Private, atomically created 0700 directory, allocated on the first print job. spool_dir: Option, next_file_id: u32, jobs: HashMap, - /// Handles whose job was abandoned after an oversized write; a later close - /// must not submit whatever was spooled before. - poisoned: HashMap, + /// Handles whose job was abandoned; their close must not submit anything. + abandoned: HashSet, + /// Started on the first closed job. + submitter: Option, } impl PrinterSpooler { pub fn new(target: PrintTarget) -> Self { Self { target, - fallback_dir: default_fallback_dir(), spool_dir: None, next_file_id: 1, jobs: HashMap::new(), - poisoned: HashMap::new(), + abandoned: HashSet::new(), + submitter: None, } } - #[cfg(test)] - fn with_fallback_dir(mut self, dir: PathBuf) -> Self { - self.fallback_dir = dir; - self + pub fn handle(&mut self, req: PrinterIoRequest) -> PduResult> { + let response = match req { + PrinterIoRequest::Create(create) => self.create(create.device_io_request), + PrinterIoRequest::Write(write) => self.write(write.device_io_request, &write.write_data), + PrinterIoRequest::Close(close) => self.close(close.device_io_request), + }; + Ok(vec![SvcMessage::from(response)]) } - pub fn handle(&mut self, req: PrinterIoRequest) -> PduResult> { - match req { - PrinterIoRequest::Create(create) => { - let file_id = self.next_file_id; - self.next_file_id = self.next_file_id.wrapping_add(1).max(1); - let response = match self.open_spool(file_id) { - Ok(job) => { - debug!(file_id, spool = ?job.spool, "Print job opened"); - self.jobs.insert(file_id, job); - DeviceCreateResponse { - device_io_reply: DeviceIoResponse::new(create.device_io_request, NtStatus::SUCCESS), - file_id, - information: Information::FILE_OPENED, - } - } - Err(error) => { - warn!(%error, "Could not open a spool file for a print job"); - DeviceCreateResponse { - device_io_reply: DeviceIoResponse::new(create.device_io_request, NtStatus::UNSUCCESSFUL), - file_id: 0, - information: Information::FILE_OPENED, - } - } - }; - Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCreateResponse(response))]) + /// Abandons the job behind an oversized write so a later close discards it. + pub fn reject_write(&mut self, req: DeviceIoRequest) -> PduResult> { + if self.jobs.contains_key(&req.file_id) { + warn!( + file_id = req.file_id, + "Print job abandoned: the server sent an oversized write" + ); + self.abandon(req.file_id); + } + Ok(vec![SvcMessage::from(write_response(req, 0, NtStatus::UNSUCCESSFUL))]) + } + + /// Discards the open and abandoned jobs of the current RDPDR initialization sequence. Jobs + /// that were already closed are still submitted. + pub fn reset(&mut self) { + for (_, job) in self.jobs.drain() { + drop(job.file); + let _ = std::fs::remove_file(job.spool); + } + self.abandoned.clear(); + } + + fn create(&mut self, request: DeviceIoRequest) -> RdpdrPdu { + if self.jobs.len() + self.abandoned.len() >= MAX_OPEN_PRINT_JOBS { + warn!(limit = MAX_OPEN_PRINT_JOBS, "Print job refused: too many jobs are open"); + return create_response(request, 0, NtStatus::UNSUCCESSFUL); + } + let file_id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + match self.open_spool(file_id) { + Ok(job) => { + debug!(file_id, spool = ?job.spool, "Print job opened"); + self.jobs.insert(file_id, job); + create_response(request, file_id, NtStatus::SUCCESS) + } + Err(error) => { + warn!(%error, "Could not open a spool file for a print job"); + create_response(request, 0, NtStatus::UNSUCCESSFUL) + } + } + } + + fn write(&mut self, request: DeviceIoRequest, data: &[u8]) -> RdpdrPdu { + let file_id = request.file_id; + let Some(job) = self.jobs.get_mut(&file_id) else { + return write_response(request, 0, NtStatus::UNSUCCESSFUL); + }; + let total = job.bytes.saturating_add(u64::try_from(data.len()).unwrap_or(u64::MAX)); + if total > MAX_PRINT_JOB_BYTES { + warn!( + file_id, + limit = MAX_PRINT_JOB_BYTES, + "Print job abandoned: it exceeds the size limit" + ); + self.abandon(file_id); + return write_response(request, 0, NtStatus::UNSUCCESSFUL); + } + match job.file.write_all(data) { + Ok(()) => { + job.bytes = total; + let length = u32::try_from(data.len()).unwrap_or(u32::MAX); + write_response(request, length, NtStatus::SUCCESS) } - PrinterIoRequest::Write(write) => { - let file_id = write.device_io_request.file_id; - let length = u32::try_from(write.write_data.len()).unwrap_or(u32::MAX); - let status = match self.jobs.get_mut(&file_id) { - Some(job) => match job.file.write_all(&write.write_data) { - Ok(()) => { - job.bytes = job.bytes.saturating_add(u64::from(length)); - NtStatus::SUCCESS - } - Err(error) => { - warn!(%error, file_id, "Could not spool print data"); - NtStatus::UNSUCCESSFUL - } - }, - None => NtStatus::UNSUCCESSFUL, - }; - Ok(vec![SvcMessage::from(RdpdrPdu::DeviceWriteResponse( - DeviceWriteResponse { - device_io_reply: DeviceIoResponse::new(write.device_io_request, status), - length, - }, - ))]) + Err(error) => { + warn!(%error, file_id, "Print job abandoned: could not spool its data"); + self.abandon(file_id); + write_response(request, 0, NtStatus::UNSUCCESSFUL) + } + } + } + + fn close(&mut self, request: DeviceIoRequest) -> RdpdrPdu { + let file_id = request.file_id; + if !self.abandoned.remove(&file_id) + && let Some(Job { spool, file, bytes }) = self.jobs.remove(&file_id) + { + drop(file); + if bytes == 0 { + debug!(?spool, "Empty print job discarded"); + let _ = std::fs::remove_file(spool); + } else { + self.queue_submission(FinishedJob { spool, bytes }); } - PrinterIoRequest::Close(close) => { - let file_id = close.device_io_request.file_id; - if let Some(spool) = self.poisoned.remove(&file_id) { - let _ = std::fs::remove_file(spool); - } else if let Some(job) = self.jobs.remove(&file_id) { - drop(job.file); - self.submit(&job.spool, job.bytes); + } + close_response(request, NtStatus::SUCCESS) + } + + /// Drops an open job's spool file so its close submits nothing, not even a partial document. + fn abandon(&mut self, file_id: u32) { + if let Some(job) = self.jobs.remove(&file_id) { + drop(job.file); + let _ = std::fs::remove_file(job.spool); + self.abandoned.insert(file_id); + } + } + + fn queue_submission(&mut self, job: FinishedJob) { + if self.submitter.is_none() { + let Some(spool_dir) = self.spool_dir.clone() else { + return; + }; + match Submitter::spawn(self.target.clone(), spool_dir) { + Ok(submitter) => self.submitter = Some(submitter), + Err(error) => { + warn!(%error, "Could not start the print submission thread; the print job is discarded"); + let _ = std::fs::remove_file(job.spool); + return; } - Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse( - DeviceCloseResponse { - device_io_response: DeviceIoResponse::new(close.device_io_request, NtStatus::SUCCESS), - }, - ))]) } } + let Some(submitter) = &self.submitter else { + return; + }; + if let Err(TrySendError::Full(job) | TrySendError::Disconnected(job)) = submitter.jobs.try_send(job) { + warn!(spool = ?job.spool, "Could not queue the print job for submission; it is discarded"); + let _ = std::fs::remove_file(job.spool); + } } fn open_spool(&mut self, file_id: u32) -> std::io::Result { @@ -161,153 +247,185 @@ impl PrinterSpooler { .open(&spool)?; Ok(Job { spool, file, bytes: 0 }) } +} - /// Abandons the job behind an oversized write so a later close discards it. - pub fn reject_write(&mut self, req: DeviceIoRequest) -> PduResult> { - if let Some(job) = self.jobs.remove(&req.file_id) { - warn!( - file_id = req.file_id, - "Print job abandoned: the server sent an oversized write" - ); - self.poisoned.insert(req.file_id, job.spool); +impl Drop for PrinterSpooler { + fn drop(&mut self) { + // A disconnected session abandons its open jobs. Jobs it already closed are still + // submitted, and the submission thread removes the spool directory once it is done. + self.reset(); + if self.submitter.take().is_none() + && let Some(dir) = self.spool_dir.take() + { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +impl Submitter { + fn spawn(target: PrintTarget, spool_dir: PathBuf) -> std::io::Result { + let (jobs, queue) = sync_channel(SUBMISSION_QUEUE_CAPACITY); + let thread = std::thread::Builder::new() + .name("ironrdp-print-submit".to_owned()) + .spawn(move || submit_jobs(&target, &spool_dir, queue))?; + Ok(Self { jobs, thread }) + } +} + +/// Submits queued jobs until the spooler is gone, then removes the spool directory. +fn submit_jobs(target: &PrintTarget, spool_dir: &Path, queue: Receiver) { + for job in queue { + match target { + PrintTarget::DefaultPrinter => print(&job, None), + PrintTarget::Printer(name) => print(&job, Some(name)), + PrintTarget::Folder(dir) => save(&job.spool, dir), } - Ok(vec![SvcMessage::from(RdpdrPdu::DeviceWriteResponse( - DeviceWriteResponse { - device_io_reply: DeviceIoResponse::new(req, NtStatus::UNSUCCESSFUL), - length: 0, - }, - ))]) - } - - fn submit(&self, spool: &Path, bytes: u64) { - if bytes == 0 { - debug!(?spool, "Empty print job discarded"); - let _ = std::fs::remove_file(spool); + let _ = std::fs::remove_file(&job.spool); + } + let _ = std::fs::remove_dir_all(spool_dir); +} + +/// Hands the job to `lp`, killing it if it has not accepted the job within [`LP_TIMEOUT`]. +fn print(job: &FinishedJob, destination: Option<&str>) { + let mut command = Command::new("lp"); + if let Some(name) = destination { + command.arg("-d").arg(name); + } + command + .arg("-t") + .arg("RDP print job") + .arg(&job.spool) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + warn!(%error, "lp is not available; the print job is discarded"); return; } - let mut command = Command::new("lp"); - match &self.target { - PrintTarget::DefaultPrinter => {} - PrintTarget::Printer(name) => { - command.arg("-d").arg(name); - } - PrintTarget::Folder(dir) => { - self.keep(spool, dir); + }; + let deadline = Instant::now() + LP_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + warn!(timeout = ?LP_TIMEOUT, "lp did not accept the print job in time; the job is discarded"); return; } - } - command.arg("-t").arg("RDP print job").arg(spool); - match command.output() { - Ok(output) if output.status.success() => { - info!( - bytes, - "Print job handed to lp: {}", - String::from_utf8_lossy(&output.stdout).trim() - ); - let _ = std::fs::remove_file(spool); - } - Ok(output) => { - warn!( - "lp refused the print job ({}); keeping it as a file instead", - String::from_utf8_lossy(&output.stderr).trim() - ); - self.keep(spool, &self.fallback_dir.clone()); - } Err(error) => { - warn!(%error, "lp is not available; keeping the print job as a file instead"); - self.keep(spool, &self.fallback_dir.clone()); + let _ = child.kill(); + let _ = child.wait(); + warn!(%error, "Could not wait for lp; the print job is discarded"); + return; } } + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_string(&mut stdout); } + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_string(&mut stderr); + } + if status.success() { + info!(bytes = job.bytes, "Print job handed to lp: {}", stdout.trim()); + } else { + warn!("lp refused the print job ({}); the job is discarded", stderr.trim()); + } +} - /// Moves the spooled job into `dir` under a readable name. - fn keep(&self, spool: &Path, dir: &Path) { - if let Err(error) = std::fs::create_dir_all(dir) { - warn!(%error, ?dir, "Could not create the print output folder"); - return; - } - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - // The destination may also be shared. Exclusive creation avoids overwriting - // existing documents or following a symlink, including across filesystems. - let mut n = 0u64; - let (destination, mut output) = loop { - let name = if n == 0 { - format!("RDP print {stamp}.ps") - } else { - format!("RDP print {stamp} ({n}).ps") - }; - let destination = dir.join(name); - match OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&destination) - { - Ok(file) => break (destination, file), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => n = n.saturating_add(1), - Err(error) => { - warn!(%error, ?destination, "Could not create the print output file"); - return; - } - } +/// Copies the spooled job into `dir` under a readable name. +fn save(spool: &Path, dir: &Path) { + if let Err(error) = std::fs::create_dir_all(dir) { + warn!(%error, ?dir, "Could not create the print output folder"); + return; + } + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // The destination may also be shared. Exclusive creation avoids overwriting + // existing documents or following a symlink, including across filesystems. + let mut n = 0u64; + let (destination, mut output) = loop { + let name = if n == 0 { + format!("RDP print {stamp}.ps") + } else { + format!("RDP print {stamp} ({n}).ps") }; - let moved = File::open(spool) - .and_then(|mut input| std::io::copy(&mut input, &mut output)) - .and_then(|_| output.flush()) - .and_then(|()| std::fs::remove_file(spool)); - match moved { - Ok(()) => info!(?destination, "Print job saved as a PostScript file"), + let destination = dir.join(name); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&destination) + { + Ok(file) => break (destination, file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => n = n.saturating_add(1), Err(error) => { - drop(output); - let _ = std::fs::remove_file(&destination); - warn!(%error, ?destination, "Could not save the print job"); + warn!(%error, ?destination, "Could not create the print output file"); + return; } } + }; + let saved = File::open(spool) + .and_then(|mut input| std::io::copy(&mut input, &mut output)) + .and_then(|_| output.flush()); + match saved { + Ok(()) => info!(?destination, "Print job saved as a PostScript file"), + Err(error) => { + drop(output); + let _ = std::fs::remove_file(&destination); + warn!(%error, ?destination, "Could not save the print job"); + } } } -impl Drop for PrinterSpooler { - fn drop(&mut self) { - // A disconnected session abandons both open and rejected jobs. - self.jobs.clear(); - if let Some(dir) = self.spool_dir.take() { - let _ = std::fs::remove_dir_all(dir); - } +/// Answers a printer request with `NOT_SUPPORTED`, in the response its major function expects. +pub(crate) fn unsupported_response(request: PrinterIoRequest) -> RdpdrPdu { + match request { + PrinterIoRequest::Create(create) => create_response(create.device_io_request, 0, NtStatus::NOT_SUPPORTED), + PrinterIoRequest::Write(write) => write_response(write.device_io_request, 0, NtStatus::NOT_SUPPORTED), + PrinterIoRequest::Close(close) => close_response(close.device_io_request, NtStatus::NOT_SUPPORTED), } } -fn default_fallback_dir() -> PathBuf { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - let downloads = home.join("Downloads"); - if downloads.is_dir() { downloads } else { home } +/// A failed create carries no file handle and no `FILE_OPENED`. +fn create_response(request: DeviceIoRequest, file_id: u32, status: NtStatus) -> RdpdrPdu { + let opened = status == NtStatus::SUCCESS; + RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse { + device_io_reply: DeviceIoResponse::new(request, status), + file_id: if opened { file_id } else { 0 }, + information: if opened { + Information::FILE_OPENED + } else { + Information::empty() + }, + }) } -impl PrintTarget { - /// Parses a target description: `default` (or an empty string), `folder:`, or a CUPS - /// destination name. - pub fn parse(value: &str) -> Self { - let value = value.trim(); - if value.is_empty() || value.eq_ignore_ascii_case("default") { - Self::DefaultPrinter - } else if let Some(dir) = value.strip_prefix("folder:") { - Self::Folder(PathBuf::from(dir)) - } else { - Self::Printer(value.to_owned()) - } - } +/// `length` is the number of bytes written, so a failed write reports 0 (MS-RDPEPC 3.2.5.1.12). +pub(crate) fn write_response(request: DeviceIoRequest, length: u32, status: NtStatus) -> RdpdrPdu { + RdpdrPdu::DeviceWriteResponse(DeviceWriteResponse { + device_io_reply: DeviceIoResponse::new(request, status), + length, + }) +} + +fn close_response(request: DeviceIoRequest, status: NtStatus) -> RdpdrPdu { + RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse { + device_io_response: DeviceIoResponse::new(request, status), + }) } #[cfg(test)] mod tests { - use ironrdp_rdpdr::pdu::efs::{ - CreateDisposition, CreateOptions, DesiredAccess, DeviceCloseRequest, DeviceCreateRequest, DeviceWriteRequest, - FileAttributes, MajorFunction, MinorFunction, SharedAccess, - }; + use ironrdp_rdpdr::pdu::efs::{DeviceCloseRequest, DeviceWriteRequest, MajorFunction, MinorFunction}; use std::os::unix::fs::{PermissionsExt as _, symlink}; use super::*; @@ -322,27 +440,38 @@ mod tests { } } + fn open_job(spooler: &mut PrinterSpooler) -> u32 { + let RdpdrPdu::DeviceCreateResponse(response) = spooler.create(io(0, MajorFunction::Create)) else { + panic!("expected a create response"); + }; + assert_eq!(response.information, Information::FILE_OPENED); + response.file_id + } + + fn close_job(spooler: &mut PrinterSpooler, file_id: u32) { + spooler + .handle(PrinterIoRequest::Close(DeviceCloseRequest::decode(io( + file_id, + MajorFunction::Close, + )))) + .expect("close"); + } + + /// Stops the submission thread once it has handled every queued job. + fn wait_for_submissions(spooler: &mut PrinterSpooler) { + if let Some(Submitter { jobs, thread }) = spooler.submitter.take() { + drop(jobs); + thread.join().expect("submission thread"); + } + } + #[test] fn a_job_streams_into_the_folder_target() { let dir = std::env::temp_dir().join(format!("ironrdp-print-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - let mut spooler = PrinterSpooler::new(PrintTarget::Folder(dir.clone())).with_fallback_dir(dir.clone()); - - let create = spooler - .handle(PrinterIoRequest::Create(DeviceCreateRequest { - device_io_request: io(0, MajorFunction::Create), - desired_access: DesiredAccess::empty(), - allocation_size: 0, - file_attributes: FileAttributes::empty(), - shared_access: SharedAccess::empty(), - create_disposition: CreateDisposition::FILE_OPEN, - create_options: CreateOptions::empty(), - path: String::new(), - })) - .expect("create"); - assert_eq!(create.len(), 1); - let file_id = spooler.jobs.keys().copied().next().expect("one open job"); + let mut spooler = PrinterSpooler::new(PrintTarget::Folder(dir.clone())); + let file_id = open_job(&mut spooler); for chunk in [&b"%!PS-Adobe-3.0\n"[..], b"showpage\n"] { spooler .handle(PrinterIoRequest::Write(DeviceWriteRequest { @@ -352,12 +481,8 @@ mod tests { })) .expect("write"); } - spooler - .handle(PrinterIoRequest::Close(DeviceCloseRequest::decode(io( - file_id, - MajorFunction::Close, - )))) - .expect("close"); + close_job(&mut spooler, file_id); + wait_for_submissions(&mut spooler); let saved: Vec<_> = std::fs::read_dir(&dir).expect("dir").flatten().collect(); assert_eq!(saved.len(), 1, "one job file"); @@ -375,27 +500,11 @@ mod tests { fn a_rejected_write_poisons_the_job_so_close_discards_it() { let dir = std::env::temp_dir().join(format!("ironrdp-print-test-poison-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - let mut spooler = PrinterSpooler::new(PrintTarget::Folder(dir.clone())).with_fallback_dir(dir.clone()); - spooler - .handle(PrinterIoRequest::Create(DeviceCreateRequest { - device_io_request: io(0, MajorFunction::Create), - desired_access: DesiredAccess::empty(), - allocation_size: 0, - file_attributes: FileAttributes::empty(), - shared_access: SharedAccess::empty(), - create_disposition: CreateDisposition::FILE_OPEN, - create_options: CreateOptions::empty(), - path: String::new(), - })) - .expect("create"); - let file_id = spooler.jobs.keys().copied().next().expect("one open job"); + let mut spooler = PrinterSpooler::new(PrintTarget::Folder(dir.clone())); + let file_id = open_job(&mut spooler); spooler.reject_write(io(file_id, MajorFunction::Write)).expect("reject"); - spooler - .handle(PrinterIoRequest::Close(DeviceCloseRequest::decode(io( - file_id, - MajorFunction::Close, - )))) - .expect("close"); + close_job(&mut spooler, file_id); + wait_for_submissions(&mut spooler); assert!( !dir.exists() || std::fs::read_dir(&dir).expect("dir").next().is_none(), "nothing saved" @@ -403,11 +512,65 @@ mod tests { } #[test] - fn targets_parse_from_a_description() { - assert!(matches!(PrintTarget::parse("default"), PrintTarget::DefaultPrinter)); - assert!(matches!(PrintTarget::parse(""), PrintTarget::DefaultPrinter)); - assert!(matches!(PrintTarget::parse("HP_LaserJet"), PrintTarget::Printer(n) if n == "HP_LaserJet")); - assert!(matches!(PrintTarget::parse("folder:/tmp/out"), PrintTarget::Folder(p) if p == Path::new("/tmp/out"))); + fn a_job_over_the_size_limit_is_abandoned() { + let mut spooler = PrinterSpooler::new(PrintTarget::DefaultPrinter); + let file_id = open_job(&mut spooler); + let spool = spooler.jobs[&file_id].spool.clone(); + spooler.jobs.get_mut(&file_id).expect("open job").bytes = MAX_PRINT_JOB_BYTES; + + let RdpdrPdu::DeviceWriteResponse(response) = spooler.write(io(file_id, MajorFunction::Write), b"x") else { + panic!("expected a write response"); + }; + assert_eq!(response.length, 0, "a failed write reports no bytes written"); + assert!(!spool.exists(), "the abandoned job's spool file is removed"); + assert!(spooler.abandoned.contains(&file_id)); + } + + #[test] + fn failed_requests_carry_no_handle_or_length() { + let mut spooler = PrinterSpooler::new(PrintTarget::DefaultPrinter); + for _ in 0..MAX_OPEN_PRINT_JOBS { + open_job(&mut spooler); + } + let RdpdrPdu::DeviceCreateResponse(response) = spooler.create(io(0, MajorFunction::Create)) else { + panic!("expected a create response"); + }; + assert_eq!(response.file_id, 0); + assert_eq!(response.information, Information::empty()); + + let RdpdrPdu::DeviceWriteResponse(response) = spooler.write(io(u32::MAX, MajorFunction::Write), b"data") else { + panic!("expected a write response"); + }; + assert_eq!(response.length, 0); + } + + #[test] + fn unsupported_requests_are_answered_with_their_own_response_type() { + let write = unsupported_response(PrinterIoRequest::Write(DeviceWriteRequest { + device_io_request: io(1, MajorFunction::Write), + offset: 0, + write_data: b"data".to_vec(), + })); + let RdpdrPdu::DeviceWriteResponse(response) = write else { + panic!("expected a write response"); + }; + assert_eq!(response.length, 0); + } + + #[test] + fn reset_discards_open_jobs() { + let mut spooler = PrinterSpooler::new(PrintTarget::DefaultPrinter); + let open = open_job(&mut spooler); + let spool = spooler.jobs[&open].spool.clone(); + let abandoned = open_job(&mut spooler); + spooler + .reject_write(io(abandoned, MajorFunction::Write)) + .expect("reject"); + + spooler.reset(); + assert!(spooler.jobs.is_empty()); + assert!(spooler.abandoned.is_empty()); + assert!(!spool.exists(), "the open job's spool file is removed"); } #[test] From 1625d3a903c6703da5ffa32b4dc2a2ae4ae464a3 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Mon, 28 Sep 2026 01:27:45 -0600 Subject: [PATCH 3/3] fix(rdpdr): reject unknown closes and bad device names Address the second review of the Linux and macOS printer backend: - Closing a FileId that no open job owns, such as one already closed, is answered with STATUS_UNSUCCESSFUL (MS-RDPEFS 3.1.5.2) instead of success. - The factory rejects an empty drive or printer name and one with an embedded NUL, since both are announced as NUL-terminated strings, as the Windows factory and the dynamic-drive path already do. - `lp`'s output is read on threads while it runs, so output beyond the pipe buffer cannot block it until the timeout and stall the queue. --- .../ironrdp-rdpdr-native/src/nix/backend.rs | 42 ++++++++++- .../ironrdp-rdpdr-native/src/nix/printer.rs | 75 ++++++++++++++----- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/crates/ironrdp-rdpdr-native/src/nix/backend.rs b/crates/ironrdp-rdpdr-native/src/nix/backend.rs index f000079ae6..344a3a24ac 100644 --- a/crates/ironrdp-rdpdr-native/src/nix/backend.rs +++ b/crates/ironrdp-rdpdr-native/src/nix/backend.rs @@ -99,10 +99,13 @@ const DEFAULT_PRINTER_DEVICE_ID: u32 = u32::MAX - 1; impl RdpdrBackendFactory for NixRdpdrBackendFactory { fn build_rdpdr_backend(&self) -> RdpdrBackendFactoryResult { let mut device_ids = HashSet::with_capacity(self.drives.len()); - for (device_id, _) in &self.drives { + 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))); } @@ -111,6 +114,9 @@ impl RdpdrBackendFactory for NixRdpdrBackendFactory { 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 @@ -139,6 +145,12 @@ impl RdpdrBackendFactory for NixRdpdrBackendFactory { } } +/// 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 { @@ -148,6 +160,10 @@ pub enum NixRdpdrBackendFactoryError { 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 { @@ -156,6 +172,13 @@ impl core::fmt::Display for NixRdpdrBackendFactoryError { 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"), } } } @@ -986,6 +1009,23 @@ mod tests { ); } + #[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()) diff --git a/crates/ironrdp-rdpdr-native/src/nix/printer.rs b/crates/ironrdp-rdpdr-native/src/nix/printer.rs index e255fc83ae..88071cd47e 100644 --- a/crates/ironrdp-rdpdr-native/src/nix/printer.rs +++ b/crates/ironrdp-rdpdr-native/src/nix/printer.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; -use std::io::{Read as _, Write as _}; +use std::io::{Read, Write as _}; use std::os::unix::fs::OpenOptionsExt as _; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -182,16 +182,20 @@ impl PrinterSpooler { fn close(&mut self, request: DeviceIoRequest) -> RdpdrPdu { let file_id = request.file_id; - if !self.abandoned.remove(&file_id) - && let Some(Job { spool, file, bytes }) = self.jobs.remove(&file_id) - { - drop(file); - if bytes == 0 { - debug!(?spool, "Empty print job discarded"); - let _ = std::fs::remove_file(spool); - } else { - self.queue_submission(FinishedJob { spool, bytes }); - } + if self.abandoned.remove(&file_id) { + return close_response(request, NtStatus::SUCCESS); + } + // MS-RDPEFS 3.1.5.2: a FileId that no open job owns, such as one already closed, is + // answered with STATUS_UNSUCCESSFUL. + let Some(Job { spool, file, bytes }) = self.jobs.remove(&file_id) else { + return close_response(request, NtStatus::UNSUCCESSFUL); + }; + drop(file); + if bytes == 0 { + debug!(?spool, "Empty print job discarded"); + let _ = std::fs::remove_file(spool); + } else { + self.queue_submission(FinishedJob { spool, bytes }); } close_response(request, NtStatus::SUCCESS) } @@ -305,6 +309,9 @@ fn print(job: &FinishedJob, destination: Option<&str>) { return; } }; + // The pipes are drained while lp runs, so output beyond the pipe buffer cannot block it. + let stdout = child.stdout.take().and_then(drain); + let stderr = child.stderr.take().and_then(drain); let deadline = Instant::now() + LP_TIMEOUT; let status = loop { match child.try_wait() { @@ -324,14 +331,8 @@ fn print(job: &FinishedJob, destination: Option<&str>) { } } }; - let mut stdout = String::new(); - let mut stderr = String::new(); - if let Some(mut pipe) = child.stdout.take() { - let _ = pipe.read_to_string(&mut stdout); - } - if let Some(mut pipe) = child.stderr.take() { - let _ = pipe.read_to_string(&mut stderr); - } + let stdout = stdout.and_then(|reader| reader.join().ok()).unwrap_or_default(); + let stderr = stderr.and_then(|reader| reader.join().ok()).unwrap_or_default(); if status.success() { info!(bytes = job.bytes, "Print job handed to lp: {}", stdout.trim()); } else { @@ -339,6 +340,19 @@ fn print(job: &FinishedJob, destination: Option<&str>) { } } +/// Reads `pipe` to its end on a thread of its own. Without the thread, the pipe is dropped, +/// which a writer sees as a closed pipe rather than a block. +fn drain(mut pipe: R) -> Option> { + std::thread::Builder::new() + .name("ironrdp-print-lp-output".to_owned()) + .spawn(move || { + let mut output = String::new(); + let _ = pipe.read_to_string(&mut output); + output + }) + .ok() +} + /// Copies the spooled job into `dir` under a readable name. fn save(spool: &Path, dir: &Path) { if let Err(error) = std::fs::create_dir_all(dir) { @@ -544,6 +558,29 @@ mod tests { assert_eq!(response.length, 0); } + #[test] + fn closing_a_file_id_that_is_not_open_fails() { + let mut spooler = PrinterSpooler::new(PrintTarget::DefaultPrinter); + let file_id = open_job(&mut spooler); + let status = |pdu| match pdu { + RdpdrPdu::DeviceCloseResponse(response) => response.device_io_response.io_status, + other => panic!("expected a close response, got {other:?}"), + }; + assert_eq!( + status(spooler.close(io(file_id, MajorFunction::Close))), + NtStatus::SUCCESS + ); + assert_eq!( + status(spooler.close(io(file_id, MajorFunction::Close))), + NtStatus::UNSUCCESSFUL, + "a second close of the same job" + ); + assert_eq!( + status(spooler.close(io(u32::MAX, MajorFunction::Close))), + NtStatus::UNSUCCESSFUL + ); + } + #[test] fn unsupported_requests_are_answered_with_their_own_response_type() { let write = unsupported_response(PrinterIoRequest::Write(DeviceWriteRequest {