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..7d3fc7fcdc 100644 --- a/crates/ironrdp-rdpdr-native/README.md +++ b/crates/ironrdp-rdpdr-native/README.md @@ -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. 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..344a3a24ac 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}; @@ -5,14 +6,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, unsupported_response, write_response}; + #[derive(Debug, Default)] pub struct NixRdpdrBackend { file_id: u32, @@ -20,6 +25,14 @@ 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, +} + +#[derive(Debug)] +struct Printer { + device_id: u32, + spooler: PrinterSpooler, } impl NixRdpdrBackend { @@ -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 + } +} + +/// 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, 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) + } +} + +/// 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> { + match self.printer.as_mut() { + 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(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, @@ -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::() + .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 + ); + } +} 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..88071cd47e --- /dev/null +++ b/crates/ironrdp-rdpdr-native/src/nix/printer.rs @@ -0,0 +1,643 @@ +//! 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, +//! 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, HashSet}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write as _}; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; +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; +use ironrdp_rdpdr::pdu::efs::{ + DeviceCloseResponse, DeviceCreateResponse, DeviceIoRequest, DeviceIoResponse, DeviceWriteResponse, Information, + NtStatus, PrinterIoRequest, +}; +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 { + /// 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: 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, + /// 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; 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, + spool_dir: None, + next_file_id: 1, + jobs: HashMap::new(), + abandoned: HashSet::new(), + submitter: None, + } + } + + 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)]) + } + + /// 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) + } + 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) { + 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) + } + + /// 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; + } + } + } + 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 { + 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 }) + } +} + +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), + } + 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; + } + }; + // 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() { + 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; + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + warn!(%error, "Could not wait for lp; the print job is discarded"); + return; + } + } + }; + 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 { + warn!("lp refused the print job ({}); the job is discarded", stderr.trim()); + } +} + +/// 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) { + 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 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"); + } + } +} + +/// 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), + } +} + +/// 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() + }, + }) +} + +/// `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::{DeviceCloseRequest, DeviceWriteRequest, MajorFunction, MinorFunction}; + 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, + } + } + + 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())); + + let file_id = open_job(&mut spooler); + 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"); + } + 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"); + 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())); + let file_id = open_job(&mut spooler); + spooler.reject_write(io(file_id, MajorFunction::Write)).expect("reject"); + 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" + ); + } + + #[test] + 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 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 { + 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] + 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"); + } +}