feat(rdp): printer redirection, saved as PostScript

An optional 'Nexus Terminal' printer (IronRDP's PostScript driver
announce) appears on the remote desktop; each job is written to
'Nexus print <time>.ps' in Downloads, with a toast saying where. Jobs
are capped at 256 MiB and empty jobs leave no file. The shared drive and
the printer now share one RDPDR backend (rdp_devices); the printer
works on every platform, the drive on Linux and macOS.

Tested with a unit test driving create/write/close; xrdp has no printer
redirection, so not exercised against a server.
This commit is contained in:
Andy Dixon
2026-10-12 18:14:31 +01:00
parent b798422735
commit 2fa56e0723
11 changed files with 354 additions and 139 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ Use Nexus Terminal when you need to:
- Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control).
- Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; text clipboard sharing in both directions.
- Password storage modes and domain logon.
- Remote audio playback, and a shared folder redirected as a drive (Linux and macOS), confined to that folder.
- Remote audio playback, a printer whose jobs are saved as PostScript files, and a shared folder redirected as a drive (Linux and macOS), confined to that folder.
### VNC capabilities
+1 -1
View File
@@ -167,7 +167,7 @@ Use SSH tunnelling for VNC whenever possible. If a VNC profile has no tunnel, Ne
- **RDP**: domain (optional), password storage mode, **Certificate Policy** and **Allow servers without NLA**. The username, host and port (3389) are the profile's own.
- **Display**: **Scale to Fit**, a fixed **Width** and **Height** (0 × 0 fits the tab) and **Resize With Tab**.
- **Proxy**: **SOCKS5** or **HTTP CONNECT**, with an optional username and password.
- **Devices**: **Play Remote Audio**, and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused.
- **Devices**: **Play Remote Audio**; **Redirect Printer**, which adds a *Nexus Terminal* printer on the remote desktop whose jobs are saved as PostScript files in your Downloads folder (up to 256 MiB per job); and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused.
- **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both.
Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so your password is checked before a session is set up. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Kerberos and smart cards are not supported.
+1
View File
@@ -485,6 +485,7 @@ impl App {
}
}
Event::Warning { message, .. } => self.s.error(message),
Event::Info { message, .. } => self.s.success(message),
Event::Irc { session, event } => {
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_event(event);
+1
View File
@@ -702,6 +702,7 @@ impl ProfileEditor {
});
section(ui, "Devices", |ui| {
check(ui, "Play Remote Audio", &mut c.audio, "");
check(ui, "Redirect Printer", &mut c.redirect_printer, "Adds a \"Nexus Terminal\" printer; jobs are saved as PostScript files in Downloads");
if cfg!(unix) {
label(ui, "Shared Folder");
ui.horizontal(|ui| {
+1 -2
View File
@@ -40,8 +40,7 @@ impl Callbacks {
pub mod proxy;
pub mod raw;
pub mod rdp;
#[cfg(unix)]
pub mod rdp_drive;
pub mod rdp_devices;
pub mod serial;
pub mod telnet;
pub mod vnc;
+20 -2
View File
@@ -52,6 +52,8 @@ pub struct Config {
pub audio: bool,
/// A local folder to share as a drive (Unix only), and the name it appears under.
pub shared_folder: Option<(std::path::PathBuf, String)>,
/// Offer a printer; jobs are saved as PostScript in this folder.
pub printer: Option<(std::path::PathBuf, super::rdp_devices::PrintedFn)>,
pub dial: Dial,
pub check_cert: CertCheck,
pub connect_timeout: Duration,
@@ -196,10 +198,25 @@ impl Client {
if self.cfg.audio {
connector.attach_static_channel(ironrdp::rdpsnd::client::Rdpsnd::new(Box::new(ironrdp_rdpsnd_native::cpal::RdpsndBackend::new())));
}
if self.cfg.shared_folder.is_some() || self.cfg.printer.is_some() {
let mut devices = super::rdp_devices::Devices::default();
let mut drives = vec![];
#[cfg(unix)]
if let Some((folder, name)) = &self.cfg.shared_folder {
let drive = super::rdp_drive::ConfinedDrive::new(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?;
connector.attach_static_channel(ironrdp::rdpdr::Rdpdr::new(Box::new(drive), "Nexus".into()).with_drives(Some(vec![(1, name.clone())])));
devices = devices.with_drive(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?;
drives.push((1, name.clone()));
}
if let Some((dir, on_printed)) = &self.cfg.printer {
devices = devices.with_printer(dir.clone(), on_printed.clone());
}
let mut rdpdr = ironrdp::rdpdr::Rdpdr::new(Box::new(devices), "Nexus".into());
if !drives.is_empty() {
rdpdr = rdpdr.with_drives(Some(drives));
}
if self.cfg.printer.is_some() {
rdpdr = rdpdr.with_printer(2, "Nexus Terminal".into());
}
connector.attach_static_channel(rdpdr);
}
let (clip_tx, clip_rx) = mpsc::channel();
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
@@ -584,6 +601,7 @@ mod tests {
allow_tls_only: true,
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(),
shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())),
printer: std::env::var("NEXUS_RDP_PRINT").ok().map(|p| (p.into(), Arc::new(|f: std::path::PathBuf| eprintln!("printed {}", f.display())) as _)),
dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)),
check_cert: Arc::new(move |fp| {
*sc.lock() = fp.to_string();
+307
View File
@@ -0,0 +1,307 @@
//! RDP device redirection: a shared folder as a drive, and a virtual printer whose jobs are saved
//! as PostScript files.
//!
//! The drive is confined to one folder. IronRDP's native backend joins the server's paths onto
//! the share without checking them, so a hostile server could reach `\..\..\.ssh\id_ed25519`.
//! Every path the server sends is checked here first: no `..` components, and after resolving
//! symlinks it must stay inside the share. Drives are Unix only (the native backend has no Windows
//! implementation); the printer works everywhere.
use ironrdp::core::impl_as_any;
use ironrdp::pdu::PduResult;
use ironrdp::rdpdr::RdpdrBackend;
use ironrdp::rdpdr::pdu::RdpdrPdu;
use ironrdp::rdpdr::pdu::efs::{
ClientDriveQueryDirectoryResponse, ClientDriveSetInformationResponse, DeviceCloseResponse, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse,
DeviceWriteResponse, FileInformationClass, Information, NtStatus, PrinterIoRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest,
};
use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode};
use ironrdp::svc::SvcMessage;
#[cfg(unix)]
use ironrdp_rdpdr_native::backend::NixRdpdrBackend;
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
/// A print job may not grow past this (a hostile server could otherwise fill the disk).
const MAX_PRINT_JOB: u64 = 256 << 20;
/// Called with the file a finished print job was saved to.
pub type PrintedFn = std::sync::Arc<dyn Fn(PathBuf) + Send + Sync>;
#[derive(Default)]
pub struct Devices {
#[cfg(unix)]
drive: Option<ConfinedDrive>,
printer: Option<Spool>,
}
impl std::fmt::Debug for Devices {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Devices")
}
}
impl_as_any!(Devices);
impl Devices {
#[cfg(unix)]
pub fn with_drive(mut self, folder: &Path) -> std::io::Result<Devices> {
self.drive = Some(ConfinedDrive::new(folder)?);
Ok(self)
}
/// Print jobs go to `dir` as `Nexus print <time>.ps`.
pub fn with_printer(mut self, dir: PathBuf, on_printed: PrintedFn) -> Devices {
self.printer = Some(Spool { dir, on_printed, jobs: HashMap::new(), next_id: 1 });
self
}
}
struct Spool {
dir: PathBuf,
on_printed: PrintedFn,
jobs: HashMap<u32, (std::fs::File, PathBuf, u64)>,
next_id: u32,
}
impl Spool {
fn handle(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> {
let pdu = match req {
PrinterIoRequest::Create(r) => {
let name = format!("Nexus print {}.ps", chrono::Local::now().format("%Y-%m-%d %H.%M.%S"));
let path = self.dir.join(name);
match std::fs::File::create(&path) {
Ok(f) => {
let id = self.next_id;
self.next_id += 1;
self.jobs.insert(id, (f, path, 0));
RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::SUCCESS),
file_id: id,
information: Information::FILE_OPENED,
})
}
Err(e) => {
tracing::warn!(error = %e, dir = %self.dir.display(), "couldn't create a print file");
RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
file_id: 0,
information: Information::empty(),
})
}
}
}
PrinterIoRequest::Write(r) => {
let n = r.write_data.len() as u64;
let ok = match self.jobs.get_mut(&r.device_io_request.file_id) {
Some((f, _, written)) if *written + n <= MAX_PRINT_JOB => {
*written += n;
f.write_all(&r.write_data).is_ok()
}
_ => false,
};
RdpdrPdu::DeviceWriteResponse(DeviceWriteResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, if ok { NtStatus::SUCCESS } else { NtStatus::UNSUCCESSFUL }),
length: if ok { n as u32 } else { 0 },
})
}
PrinterIoRequest::Close(r) => {
if let Some((f, path, written)) = self.jobs.remove(&r.device_io_request.file_id) {
drop(f);
if written == 0 {
let _ = std::fs::remove_file(&path);
} else {
(self.on_printed)(path);
}
}
RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse { device_io_response: DeviceIoResponse::new(r.device_io_request, NtStatus::SUCCESS) })
}
};
Ok(vec![SvcMessage::from(pdu)])
}
}
impl RdpdrBackend for Devices {
fn handle_server_device_announce_response(&mut self, _: ServerDeviceAnnounceResponse) -> PduResult<()> {
Ok(())
}
fn handle_scard_call(&mut self, _: DeviceControlRequest<ScardIoCtlCode>, _: ScardCall) -> PduResult<()> {
Ok(())
}
fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
#[cfg(unix)]
if let Some(d) = &mut self.drive {
return d.handle(req);
}
ConfinedDrive::refuse(req)
}
fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> {
match &mut self.printer {
Some(p) => p.handle(req),
None => Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse {
device_io_response: DeviceIoResponse::new(req.into_device_io_request(), NtStatus::NOT_SUPPORTED),
}))]),
}
}
}
#[derive(Debug)]
struct ConfinedDrive {
#[cfg(unix)]
inner: NixRdpdrBackend,
#[cfg_attr(not(unix), allow(dead_code))]
base: PathBuf,
}
impl ConfinedDrive {
pub fn new(folder: &Path) -> std::io::Result<ConfinedDrive> {
let base = folder.canonicalize()?;
if !base.is_dir() {
return Err(std::io::Error::other(format!("{} is not a folder", base.display())));
}
Ok(ConfinedDrive {
#[cfg(unix)]
inner: NixRdpdrBackend::new(base.to_string_lossy().trim_end_matches('/').to_string()),
base,
})
}
/// Whether a server path (`\dir\file`, Windows separators) stays inside the share.
fn allowed(&self, server_path: &str) -> bool {
let rel = server_path.replace('\\', "/");
if rel.contains('\0') {
return false;
}
let mut p = self.base.clone();
for c in rel.split('/').filter(|c| !c.is_empty() && *c != ".") {
if c == ".." {
return false;
}
p.push(c);
}
// The deepest part that exists must resolve (through any symlinks) inside the share; what
// doesn't exist yet can't be a link.
let mut probe = p.as_path();
loop {
if probe.symlink_metadata().is_ok() {
return probe.canonicalize().is_ok_and(|real| real.starts_with(&self.base));
}
match probe.parent() {
Some(parent) if parent.starts_with(&self.base) => probe = parent,
_ => return false,
}
}
}
fn refuse(req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
tracing::warn!("RDP server asked for a path outside the shared folder; refused");
let pdu = match req {
ServerDriveIoRequest::ServerCreateDriveRequest(r) => RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
file_id: 0,
information: Information::empty(),
}),
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => RdpdrPdu::ClientDriveQueryDirectoryResponse(ClientDriveQueryDirectoryResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::NO_MORE_FILES),
buffer: None,
}),
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => RdpdrPdu::ClientDriveSetInformationResponse(
ClientDriveSetInformationResponse::new(&r, NtStatus::ACCESS_DENIED).map_err(|e| ironrdp::pdu::encode_err!(e))?,
),
_ => return Ok(vec![]),
};
Ok(vec![SvcMessage::from(pdu)])
}
}
impl ConfinedDrive {
#[cfg(unix)]
fn handle(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
let ok = match &req {
ServerDriveIoRequest::ServerCreateDriveRequest(r) => self.allowed(&r.path),
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => r.initial_query == 0 || self.allowed(r.path.trim_end_matches('*')),
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => match &r.set_buffer {
FileInformationClass::Rename(info) => self.allowed(&info.file_name),
_ => true,
},
// Everything else names a file by an id that a checked create handed out.
_ => true,
};
if ok { self.inner.handle_drive_io_request(req) } else { Self::refuse(req) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn print_jobs_are_saved_and_capped() {
use ironrdp::rdpdr::pdu::efs::{
CreateDisposition, CreateOptions, DesiredAccess, DeviceCloseRequest, DeviceCreateRequest, DeviceIoRequest, DeviceWriteRequest, FileAttributes,
MajorFunction, SharedAccess,
};
let dir = std::env::temp_dir().join(format!("nexus-print-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let printed: std::sync::Arc<parking_lot::Mutex<Vec<PathBuf>>> = Default::default();
let p2 = printed.clone();
let mut dev = Devices::default().with_printer(dir.clone(), std::sync::Arc::new(move |f| p2.lock().push(f)));
let io = |file_id, major| DeviceIoRequest { device_id: 2, file_id, completion_id: 1, major_function: major, minor_function: 0.into() };
let create = || {
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(),
})
};
let write = |id, data: &[u8]| {
PrinterIoRequest::Write(DeviceWriteRequest { device_io_request: io(id, MajorFunction::Write), offset: 0, write_data: data.to_vec() })
};
let close = |id| PrinterIoRequest::Close(DeviceCloseRequest { device_io_request: io(id, MajorFunction::Close) });
dev.handle_printer_io_request(create()).unwrap();
dev.handle_printer_io_request(write(1, b"%!PS-Adobe-3.0\n")).unwrap();
dev.handle_printer_io_request(write(1, b"showpage\n")).unwrap();
dev.handle_printer_io_request(close(1)).unwrap();
let files = printed.lock().clone();
assert_eq!(files.len(), 1);
assert_eq!(std::fs::read_to_string(&files[0]).unwrap(), "%!PS-Adobe-3.0\nshowpage\n");
// An empty job leaves no file behind; writes past the cap are refused.
dev.handle_printer_io_request(create()).unwrap();
dev.handle_printer_io_request(close(2)).unwrap();
dev.handle_printer_io_request(create()).unwrap();
dev.printer.as_mut().unwrap().jobs.get_mut(&3).unwrap().2 = MAX_PRINT_JOB;
dev.handle_printer_io_request(write(3, b"x")).unwrap();
assert_eq!(dev.printer.as_ref().unwrap().jobs[&3].2, MAX_PRINT_JOB);
assert_eq!(printed.lock().len(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn server_paths_stay_inside_the_share() {
let root = std::env::temp_dir().join(format!("nexus-drive-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let share = root.join("share");
std::fs::create_dir_all(share.join("sub")).unwrap();
std::fs::write(root.join("secret"), "x").unwrap();
std::os::unix::fs::symlink(&root, share.join("escape")).unwrap();
std::os::unix::fs::symlink(share.join("sub"), share.join("inside")).unwrap();
let d = ConfinedDrive::new(&share).unwrap();
for ok in ["\\", "\\sub", "\\sub\\new.txt", "\\new\\deeper\\file", "\\inside\\x", "\\.\\sub"] {
assert!(d.allowed(ok), "{ok}");
}
for bad in ["\\..\\secret", "\\sub\\..\\..\\secret", "\\escape\\secret", "\\escape\\new", "\\a\0b"] {
assert!(!d.allowed(bad), "{bad:?}");
}
std::fs::remove_dir_all(root).unwrap();
}
}
-131
View File
@@ -1,131 +0,0 @@
//! Drive redirection for RDP, confined to one shared folder. IronRDP's native backend joins the
//! server's paths onto the share without checking them, so a hostile server could reach
//! `\..\..\.ssh\id_ed25519`. Every path the server sends is checked here first: no `..`
//! components, and after resolving symlinks it must stay inside the share. Unix only (the native
//! backend has no Windows implementation).
use ironrdp::core::impl_as_any;
use ironrdp::pdu::PduResult;
use ironrdp::rdpdr::RdpdrBackend;
use ironrdp::rdpdr::pdu::RdpdrPdu;
use ironrdp::rdpdr::pdu::efs::{
ClientDriveQueryDirectoryResponse, ClientDriveSetInformationResponse, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse, FileInformationClass,
Information, NtStatus, ServerDeviceAnnounceResponse, ServerDriveIoRequest,
};
use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode};
use ironrdp::svc::SvcMessage;
use ironrdp_rdpdr_native::backend::NixRdpdrBackend;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct ConfinedDrive {
inner: NixRdpdrBackend,
base: PathBuf,
}
impl_as_any!(ConfinedDrive);
impl ConfinedDrive {
pub fn new(folder: &Path) -> std::io::Result<ConfinedDrive> {
let base = folder.canonicalize()?;
if !base.is_dir() {
return Err(std::io::Error::other(format!("{} is not a folder", base.display())));
}
Ok(ConfinedDrive { inner: NixRdpdrBackend::new(base.to_string_lossy().trim_end_matches('/').to_string()), base })
}
/// Whether a server path (`\dir\file`, Windows separators) stays inside the share.
fn allowed(&self, server_path: &str) -> bool {
let rel = server_path.replace('\\', "/");
if rel.contains('\0') {
return false;
}
let mut p = self.base.clone();
for c in rel.split('/').filter(|c| !c.is_empty() && *c != ".") {
if c == ".." {
return false;
}
p.push(c);
}
// The deepest part that exists must resolve (through any symlinks) inside the share; what
// doesn't exist yet can't be a link.
let mut probe = p.as_path();
loop {
if probe.symlink_metadata().is_ok() {
return probe.canonicalize().is_ok_and(|real| real.starts_with(&self.base));
}
match probe.parent() {
Some(parent) if parent.starts_with(&self.base) => probe = parent,
_ => return false,
}
}
}
fn refuse(req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
tracing::warn!("RDP server asked for a path outside the shared folder; refused");
let pdu = match req {
ServerDriveIoRequest::ServerCreateDriveRequest(r) => RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
file_id: 0,
information: Information::empty(),
}),
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => RdpdrPdu::ClientDriveQueryDirectoryResponse(ClientDriveQueryDirectoryResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::NO_MORE_FILES),
buffer: None,
}),
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => RdpdrPdu::ClientDriveSetInformationResponse(
ClientDriveSetInformationResponse::new(&r, NtStatus::ACCESS_DENIED).map_err(|e| ironrdp::pdu::encode_err!(e))?,
),
_ => return Ok(vec![]),
};
Ok(vec![SvcMessage::from(pdu)])
}
}
impl RdpdrBackend for ConfinedDrive {
fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()> {
self.inner.handle_server_device_announce_response(pdu)
}
fn handle_scard_call(&mut self, req: DeviceControlRequest<ScardIoCtlCode>, call: ScardCall) -> PduResult<()> {
self.inner.handle_scard_call(req, call)
}
fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
let ok = match &req {
ServerDriveIoRequest::ServerCreateDriveRequest(r) => self.allowed(&r.path),
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => r.initial_query == 0 || self.allowed(r.path.trim_end_matches('*')),
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => match &r.set_buffer {
FileInformationClass::Rename(info) => self.allowed(&info.file_name),
_ => true,
},
// Everything else names a file by an id that a checked create handed out.
_ => true,
};
if ok { self.inner.handle_drive_io_request(req) } else { Self::refuse(req) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_paths_stay_inside_the_share() {
let root = std::env::temp_dir().join(format!("nexus-drive-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let share = root.join("share");
std::fs::create_dir_all(share.join("sub")).unwrap();
std::fs::write(root.join("secret"), "x").unwrap();
std::os::unix::fs::symlink(&root, share.join("escape")).unwrap();
std::os::unix::fs::symlink(share.join("sub"), share.join("inside")).unwrap();
let d = ConfinedDrive::new(&share).unwrap();
for ok in ["\\", "\\sub", "\\sub\\new.txt", "\\new\\deeper\\file", "\\inside\\x", "\\.\\sub"] {
assert!(d.allowed(ok), "{ok}");
}
for bad in ["\\..\\secret", "\\sub\\..\\..\\secret", "\\escape\\secret", "\\escape\\new", "\\a\0b"] {
assert!(!d.allowed(bad), "{bad:?}");
}
std::fs::remove_dir_all(root).unwrap();
}
}
+2
View File
@@ -413,6 +413,8 @@ pub struct RdpConfig {
pub audio: bool,
/// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
pub shared_folder: String,
/// Offer a printer; print jobs are saved as PostScript files in Downloads.
pub redirect_printer: bool,
/// Trust policy for the server's TLS certificate (pinned like an SSH host key).
#[serde(deserialize_with = "lenient")]
pub host_key_policy: HostKeyPolicy,
+5
View File
@@ -65,6 +65,11 @@ pub enum Event {
session: String,
error: String,
},
/// Something the user might like to know (e.g. where a print job was saved).
Info {
session: String,
message: String,
},
/// Something the user should know about that didn't stop the session (e.g. a credential that
/// couldn't be saved).
Warning {
+13
View File
@@ -356,6 +356,12 @@ impl Manager {
allow_tls_only: rc.allow_tls_only,
audio: rc.audio,
shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
printer: rc.redirect_printer.then(|| {
let (me, sid) = (self.clone(), id.to_string());
let on_printed: clients::rdp_devices::PrintedFn =
Arc::new(move |f: PathBuf| me.emit(Event::Info { session: sid.clone(), message: format!("Print job saved to {}", f.display()) }));
(downloads_dir(), on_printed)
}),
dial,
check_cert,
connect_timeout: timeout,
@@ -418,3 +424,10 @@ pub(super) fn connect_vnc_client(
Err(e) => Err(e),
}
}
/// Where print jobs and similar go: ~/Downloads when it exists, else the home folder.
fn downloads_dir() -> PathBuf {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
let d = home.join("Downloads");
if d.is_dir() { d } else { home }
}