diff --git a/crates/ironrdp-activex/src/rpc.rs b/crates/ironrdp-activex/src/rpc.rs index 5ee0a55cbc..2aa244c1c3 100644 --- a/crates/ironrdp-activex/src/rpc.rs +++ b/crates/ironrdp-activex/src/rpc.rs @@ -561,7 +561,10 @@ async fn handle_request(shared: &Arc, dispatcher: isize, request: Reques | Request::ClipboardGetImage | Request::ClipboardSetImage { .. } | Request::ClipboardGetHtml - | Request::ClipboardSetHtml { .. } => Response::typed_error( + | Request::ClipboardSetHtml { .. } + | Request::ClipboardSetFiles { .. } + | Request::ClipboardListFiles + | Request::ClipboardGetFile { .. } => Response::typed_error( AgentErrorCategory::Unavailable, "clipboard access is unavailable through ActiveX", ), diff --git a/crates/ironrdp-agent/src/cli.rs b/crates/ironrdp-agent/src/cli.rs index e7ce8a30d8..64239c8cb2 100644 --- a/crates/ironrdp-agent/src/cli.rs +++ b/crates/ironrdp-agent/src/cli.rs @@ -128,6 +128,14 @@ enum Command { #[arg(long, value_parser = parse_clipboard_html)] html: String, }, + /// Offer local files to the remote via the clipboard file-list mechanism. Each path names a + /// single regular file; a directory is rejected. + ClipboardSetFiles(ClipboardSetFilesArgs), + /// List the remote's currently offered files, if any (metadata only; nothing is fetched). + ClipboardListFiles, + /// Fetch one file's full contents from the remote by its position in the last + /// `clipboard-list-files` listing. + ClipboardGetFile(ClipboardGetFileArgs), /// Send one MS-RDPEI touch contact sample (legal flag sets only). Touch { #[arg(long, default_value_t = 0)] @@ -613,6 +621,24 @@ struct ClipboardSetImageArgs { path: PathBuf, } +#[derive(Args, Debug)] +struct ClipboardSetFilesArgs { + /// One or more local file paths to offer. A directory is rejected; folders are not supported. + #[arg(required = true)] + paths: Vec, +} + +#[derive(Args, Debug)] +struct ClipboardGetFileArgs { + /// Position in the last `clipboard-list-files` listing. + index: i32, + /// Destination path. Required rather than defaulted from the remote's own file name, which + /// is untrusted input (the same reasoning `ironrdp_cliprdr`'s own docs give for not writing + /// a remote-supplied name straight to disk). + #[arg(long = "out")] + out: PathBuf, +} + #[derive(Clone, Copy, Debug, ValueEnum)] enum CliMouseButton { Left, @@ -991,6 +1017,28 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { } Request::ClipboardSetImage { png } } + Command::ClipboardSetFiles(args) => { + let paths = args + .paths + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + Request::ClipboardSetFiles { paths } + } + Command::ClipboardListFiles => Request::ClipboardListFiles, + Command::ClipboardGetFile(args) => { + let response = transport::send_request(&endpoint, &Request::ClipboardGetFile { index: args.index }).await?; + let payload = match response { + Response::Ok(payload) => payload, + Response::Err(message) => anyhow::bail!("{message}"), + }; + let Payload::ClipboardFile(data) = payload else { + anyhow::bail!("unexpected response to clipboard-get-file request"); + }; + std::fs::write(&args.out, &data).with_context(|| format!("write {}", args.out.display()))?; + println!("wrote {} ({} bytes)", args.out.display(), data.len()); + return Ok(()); + } Command::MouseMove { x, y } => Request::MouseMove { x, y }, Command::MouseButton { button, pressed } => Request::MouseButton { button: button.into_button(), @@ -2180,6 +2228,35 @@ fn print_payload(payload: Payload) { }, // Handled out-of-band by the `ClipboardGetImage` command, never printed here. Payload::ClipboardImage(png) => println!("clipboard image ({} bytes)", png.as_ref().map_or(0, Vec::len)), + Payload::ClipboardFileList(files) => match files { + Some(files) if files.is_empty() => println!("(no files)"), + Some(files) => { + for (index, file) in files.iter().enumerate() { + // `name`/`relative_path` come from the remote peer. `CLIPRDR` sanitization + // removes path traversal and null bytes but not terminal control sequences; + // `escape_debug` keeps printable Unicode readable while escaping the ANSI/OSC + // control characters an unescaped print would otherwise pass straight to the + // terminal. + let path = match &file.relative_path { + Some(relative_path) => { + format!("{}\\{}", relative_path.escape_debug(), file.name.escape_debug()) + } + None => file.name.escape_debug().to_string(), + }; + let kind = if file.is_directory { "dir" } else { "file" }; + let size = file.size.map_or_else(|| "?".to_owned(), |size| size.to_string()); + // Unix seconds, not a formatted date: keeps this crate free of a date/time + // dependency for what is otherwise a plain integer field. + let mtime = file + .last_write_time + .map_or_else(|| "?".to_owned(), |time| time.to_string()); + println!("{index}: {kind} {size:>12} {mtime:>10} {path}"); + } + } + None => println!("(no files on the remote clipboard)"), + }, + // Handled out-of-band by the `ClipboardGetFile` command, never printed here. + Payload::ClipboardFile(data) => println!("clipboard file ({} bytes)", data.len()), } } diff --git a/crates/ironrdp-agent/src/help.rs b/crates/ironrdp-agent/src/help.rs index 79b83c464d..40d01a7205 100644 --- a/crates/ironrdp-agent/src/help.rs +++ b/crates/ironrdp-agent/src/help.rs @@ -154,10 +154,12 @@ Override with `--endpoint ` on any subcommand. ## Clipboard -Text (`CF_UNICODETEXT`), images (`CF_DIB`/`CF_DIBV5`, as PNG files), and HTML fragments -(`HTML Format`); no file transfer. Local content is a single logical item: setting one replaces -whatever was set before, regardless of kind. A remote copy is requested image over HTML over text, -richest representation first, when the remote offers more than one. +Text (`CF_UNICODETEXT`), images (`CF_DIB`/`CF_DIBV5`, as PNG files), HTML fragments +(`HTML Format`), and files (the `FileGroupDescriptorW` file-list mechanism); no folders. Local +content is a single logical item: setting one replaces whatever was set before, regardless of kind. +A remote copy is requested files over image over HTML over text, richest representation first, +when the remote offers more than one. File listing is metadata only; a file's contents are fetched +only on explicit request. - `clipboard-get` Print the last text received from the remote clipboard, or `(empty)` if none has arrived yet. Requires an active session. @@ -172,10 +174,28 @@ richest representation first, when the remote offers more than one. `clipboard-set`. - `clipboard-get-html` Print the last HTML fragment received from the remote clipboard, or `(empty)` if the current remote item isn't - HTML (nothing has arrived, or the last copy was text or an - image). + HTML (nothing has arrived, or the last copy was text, an + image, or files). - `clipboard-set-html --html HTML` Set the local clipboard HTML fragment and advertise it to the remote. Same before-connect behavior as `clipboard-set`. +- `clipboard-set-files PATH...` Offer one or more local files to the remote via the clipboard + file-list mechanism. Each path must be a regular file; a + directory is rejected outright, not skipped. Works before a + session connects too: the offer is stored and advertised as + soon as the clipboard channel initializes. Once a session is + active, it must have negotiated file transfer support, or the + call fails. +- `clipboard-list-files` List the remote's currently offered files (name, path within + the copied collection, size, last-write time as Unix seconds, + and whether it is a directory entry), or a no-files message if + none are offered. Nothing is downloaded; this only inspects + metadata already received. +- `clipboard-get-file INDEX --out PATH` + Fetch one file's full contents by its position in the last + `clipboard-list-files` listing and write it to `PATH`. Fails + cleanly on a directory entry, an out-of-range index, or a + file too large for the RPC transport, rather than attempt a + partial or corrupted download. ## NOW remote execution (requires an active, connected RDP session) diff --git a/crates/ironrdp-daemon/Cargo.toml b/crates/ironrdp-daemon/Cargo.toml index 729b934474..7315dbbb19 100644 --- a/crates/ironrdp-daemon/Cargo.toml +++ b/crates/ironrdp-daemon/Cargo.toml @@ -36,6 +36,7 @@ ironrdp-rail = { version = "0.1.0", path = "../ironrdp-rail" } ironrdp-connector = { path = "../ironrdp-connector" } ironrdp-session = { path = "../ironrdp-session" } now-proto-pdu = { version = "0.4", features = ["std"] } +tokio = { version = "1", features = ["test-util"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/ironrdp-daemon/src/clipboard.rs b/crates/ironrdp-daemon/src/clipboard.rs index 58aa18c071..97739fb496 100644 --- a/crates/ironrdp-daemon/src/clipboard.rs +++ b/crates/ironrdp-daemon/src/clipboard.rs @@ -1,32 +1,63 @@ //! In-memory `CLIPRDR` backend for the headless agent daemon. //! //! Bridges `CLIPRDR` to the `clipboard-get`/`clipboard-set` IPC operations. Plain Unicode text -//! (`CF_UNICODETEXT`), images (`CF_DIB`/`CF_DIBV5`, stored as PNG), and HTML fragments (the -//! registered `HTML Format`); no file transfer. -//! A headless daemon has no host clipboard of its own, so this backend holds the last content +//! (`CF_UNICODETEXT`), images (`CF_DIB`/`CF_DIBV5`, stored as PNG), HTML fragments (the +//! registered `HTML Format`), and files (the `FileGroupDescriptorW` file-list mechanism). A +//! headless daemon has no host clipboard of its own, so this backend holds the last content //! pushed by a `clipboard-set*` operation as its local clipboard content and the last content //! received from the remote as its remote clipboard content, both behind a lock shared with the -//! daemon's IPC handlers. Content is a single logical item at a time (text, image, or HTML, not -//! several at once), matching how each `clipboard-set*` call replaces whatever was there before. - +//! daemon's IPC handlers. Content is a single logical item at a time (text, image, HTML, or +//! files, not several at once), matching how each `clipboard-set*` call replaces whatever was +//! there before. +//! +//! Files are a structurally different mechanism from the other three: `CLIPRDR`'s delayed-render +//! file-list PDUs (`initiate_file_copy`/`on_file_contents_request`/`on_remote_file_list`), not +//! `FormatDataRequest`/`FormatDataResponse`. `ironrdp_cliprdr::Cliprdr` owns the whole lock +//! lifecycle (snapshotting the local file list on an incoming `LockData`, auto-locking when a +//! remote file list arrives, timeout-driven expiry), but that internal snapshot is only used for +//! `Cliprdr`'s own index-bounds validation and is never exposed to the backend. This backend's +//! `on_lock`/`on_unlock` therefore keep their own snapshot (`ClipboardState::local_locked_lists`) +//! so `build_file_contents_response` can serve a locked request from the content that was +//! actually locked rather than whatever `local`/`local_file_paths` hold by the time the request +//! arrives. `on_outgoing_locks_expired` is the mirror image on the other direction: it aborts any +//! active `clipboard-get-file` fetch bound to the expiring lock rather than let it continue +//! against a remote clipboard that has since changed underneath it. + +use std::collections::HashMap; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use ironrdp_client::rdp::RdpInputSender; use ironrdp_cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy, CliprdrBackend, CliprdrBackendFactory}; +use ironrdp_cliprdr::chunked_fetch::{ChunkedFetch, ChunkedFetchProgress}; use ironrdp_cliprdr::pdu::{ - ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, FileContentsRequest, - FileContentsResponse, FormatDataRequest, FormatDataResponse, LockDataId, + ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, FileContentsFlags, + FileContentsRequest, FileContentsResponse, FileDescriptor, FormatDataRequest, FormatDataResponse, LockDataId, }; use ironrdp_cliprdr_format::{bitmap, html}; use ironrdp_pdu::ironrdp_core::{IntoOwned as _, impl_as_any}; use ironrdp_rpc::ipc::{MAX_CLIPBOARD_HTML_BYTES, MAX_CLIPBOARD_IMAGE_BYTES}; use tracing::debug; +/// Chunk size `clipboard_get_file` drives `ChunkedFetch` with, in bytes. +/// +/// A pragmatic middle ground: large enough that a multi-megabyte file does not need thousands of +/// round trips, small enough that one chunk comfortably fits an IPC frame's own overhead. +pub(crate) const FILE_FETCH_CHUNK_SIZE: u32 = 256 * 1024; + +/// Cap on `ClipboardState::local_locked_lists`, mirroring `ironrdp_cliprdr::Cliprdr`'s own +/// `MAX_LOCKED_FILE_LISTS` bound on the identical growth risk: a remote that sends Lock PDUs +/// under many distinct clipDataIds without ever unlocking must not grow this map unbounded. +const MAX_LOCAL_LOCKED_LISTS: usize = 100; + /// One piece of clipboard content, in the daemon's own internal representation. /// /// Images are stored as PNG bytes regardless of which `CF_DIB`/`CF_DIBV5` format the remote asks /// for or offers; conversion to/from the wire's DIB byte layout happens at the point of use via -/// `ironrdp_cliprdr_format::bitmap`. +/// `ironrdp_cliprdr_format::bitmap`. Files are the wire-shaped metadata list, used identically for +/// what we offer locally and what the remote last advertised: local disk paths backing an offered +/// list live separately in `ClipboardState::local_file_paths`, since `FileDescriptor` itself +/// carries no local filesystem path. #[derive(Debug, Clone)] pub(crate) enum ClipboardContent { Text(String), @@ -34,6 +65,7 @@ pub(crate) enum ClipboardContent { /// Plain HTML fragment text, not yet wrapped in the `CF_HTML` clipboard-fragment envelope; /// wrapping happens at the point of use via `ironrdp_cliprdr_format::html`. Html(String), + Files(Vec), } /// The format ID this backend assigns when advertising the registered `HTML Format` to the @@ -45,12 +77,105 @@ fn html_format_id() -> ClipboardFormatId { } /// Clipboard content shared between the `CLIPRDR` backend and the daemon's IPC handlers. -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct ClipboardState { /// Set by a `clipboard-set*` operation; advertised to the remote and served on request. pub(crate) local: Option, /// Set from the remote's last copy; read by a `clipboard-get*` operation. pub(crate) remote: Option, + /// Local filesystem paths backing `local`'s entries when `local` is + /// `Some(ClipboardContent::Files(_))`, parallel-indexed to that list. Needed to serve + /// `on_file_contents_request` by reading the actual bytes; empty otherwise. + pub(crate) local_file_paths: Vec, + /// The `clipDataId` of the lock automatically covering `remote`'s file list, when `remote` is + /// `Some(ClipboardContent::Files(_))` and locking was negotiated. Passed to `ChunkedFetch` so + /// a fetch stays bound to the snapshot it was listed against. + pub(crate) remote_file_lock_id: Option, + /// The capabilities the active session negotiated, updated by + /// `CliprdrBackend::on_process_negotiated_capabilities`. `clipboard_set_files` and + /// `clipboard_get_file` check this before sending a file-transfer message: `Cliprdr` itself + /// hard-errors a file-transfer call when `STREAM_FILECLIP_ENABLED` was not negotiated, and + /// that error is session-fatal by the time it reaches `ironrdp-client`'s dispatcher, so this + /// backend must fail cleanly at the IPC layer instead of sending the message at all. + pub(crate) negotiated_capabilities: ClipboardGeneralCapabilityFlags, + /// The in-progress `clipboard-get-file` fetch, if any. Only one is ever driven at a time. + pub(crate) active_fetch: Option, + /// The `clipDataId` `active_fetch` is bound to, if locking was negotiated. Tracked + /// separately from `active_fetch` itself (`ChunkedFetch` does not expose its own bound id) + /// so `on_outgoing_locks_expired` can recognize and abort a fetch whose lock just expired. + pub(crate) active_fetch_lock_id: Option, + /// The last [`ChunkedFetchProgress`] `on_file_contents_response` observed for `active_fetch`, + /// once it is `Complete` or `Failed`. `ChunkedFetch::is_finished` collapses both outcomes to + /// one bool; `clipboard_get_file` needs to tell them apart to know whether to return the + /// fetched bytes or an error, so this is tracked alongside rather than re-derived. + pub(crate) active_fetch_result: Option, + /// The `stream_id` of the fetch `active_fetch_result` belongs to, whenever + /// `active_fetch_result` is `Some`. Needed because `abort_active_fetch` clears `active_fetch` + /// in the same step it sets `active_fetch_result`, so by the time a waiter checks, there may + /// be no `active_fetch` left to compare its own `stream_id` against; without this, a later, + /// unrelated fetch reusing the slot could finish and have its result stolen by a stale + /// waiter still holding an older `stream_id`. Always set and cleared in lockstep with + /// `active_fetch_result`. + pub(crate) active_fetch_result_stream_id: Option, + /// Snapshots of `(local, local_file_paths)` at the moment each was locked by the remote + /// (`on_lock`), keyed by clipDataId. `Cliprdr` keeps its own snapshot for index-bounds + /// validation but does not expose it to the backend, so `build_file_contents_response` + /// consults this instead of the possibly-since-replaced `local`/`local_file_paths` when a + /// request carries a `data_id`. Cleared on the matching `on_unlock`; capped at + /// `MAX_LOCAL_LOCKED_LISTS`. + pub(crate) local_locked_lists: HashMap, Vec)>, + /// The `stream_id` the next `clipboard-get-file` fetch will use, incremented on every use. + /// + /// `Cliprdr` retains an outstanding `FileContentsRequest` until its own response/transfer + /// timeout even after `clipboard_get_file` gives up on it and clears `active_fetch`, so a + /// later fetch must not reuse an id that request might still answer against: a delayed + /// response for the old fetch would otherwise match the new fetch's id in + /// `on_file_contents_response` and be accepted into it, corrupting its output. + next_file_stream_id: u32, +} + +impl ClipboardState { + /// Returns a fresh `stream_id` for a new file-contents fetch, never reused by an earlier one. + pub(crate) fn next_file_stream_id(&mut self) -> u32 { + let id = self.next_file_stream_id; + self.next_file_stream_id = self.next_file_stream_id.wrapping_add(1); + id + } + + /// Clears the active fetch and marks it failed, if one exists. Returns whether it did, so + /// the caller knows whether to wake `file_fetch_notify` afterward (which must happen after + /// releasing this state's lock, so it is not done here). Shared by every place an in-progress + /// fetch needs to be abandoned: the remote clipboard changing (`on_remote_copy`), an active + /// fetch's lock expiring (`on_outgoing_locks_expired`), that lock being released immediately + /// by a new `clipboard-set-files` (`on_outgoing_locks_cleared`), and a session transition + /// (`Daemon::connect`) leaving one behind from the outgoing session. + pub(crate) fn abort_active_fetch(&mut self) -> bool { + let Some(fetch) = self.active_fetch.take() else { + return false; + }; + self.active_fetch_lock_id = None; + self.active_fetch_result = Some(ChunkedFetchProgress::Failed); + self.active_fetch_result_stream_id = Some(fetch.stream_id()); + true + } +} + +impl Default for ClipboardState { + fn default() -> Self { + Self { + local: None, + remote: None, + local_file_paths: Vec::new(), + remote_file_lock_id: None, + negotiated_capabilities: ClipboardGeneralCapabilityFlags::empty(), + active_fetch: None, + active_fetch_result: None, + active_fetch_result_stream_id: None, + active_fetch_lock_id: None, + local_locked_lists: HashMap::new(), + next_file_stream_id: 1, + } + } } /// Forwards `CLIPRDR` events into the session's bounded input channel. @@ -75,11 +200,20 @@ impl ClipboardMessageProxy for AgentClipboardMessageProxy { pub(crate) struct AgentCliprdrBackendFactory { state: Arc>, input_tx: RdpInputSender, + file_fetch_notify: Arc, } impl AgentCliprdrBackendFactory { - pub(crate) fn new(state: Arc>, input_tx: RdpInputSender) -> Self { - Self { state, input_tx } + pub(crate) fn new( + state: Arc>, + input_tx: RdpInputSender, + file_fetch_notify: Arc, + ) -> Self { + Self { + state, + input_tx, + file_fetch_notify, + } } } @@ -88,6 +222,7 @@ impl CliprdrBackendFactory for AgentCliprdrBackendFactory { Box::new(AgentCliprdrBackend { state: Arc::clone(&self.state), proxy: AgentClipboardMessageProxy(self.input_tx.clone()), + file_fetch_notify: Arc::clone(&self.file_fetch_notify), pending_paste: None, pending_since_ms: None, desired_paste: None, @@ -109,6 +244,11 @@ enum PendingPaste { dibv5: bool, }, Html, + /// File-list paste. The list itself arrives through the separate + /// [`CliprdrBackend::on_remote_file_list`] callback, not `on_format_data_response`; this + /// variant exists so `on_remote_copy`'s one-outstanding-request bookkeeping and timeout still + /// cover the file-list case the same way as the others. + Files, } /// How long an outstanding paste request is given to answer before a newer remote copy is allowed @@ -125,6 +265,9 @@ const PENDING_PASTE_TIMEOUT_MS: u64 = 5_000; struct AgentCliprdrBackend { state: Arc>, proxy: AgentClipboardMessageProxy, + /// Woken whenever `on_file_contents_response` advances or finishes `active_fetch`, so + /// `clipboard_get_file`'s wait loop knows to re-check state instead of polling. + file_fetch_notify: Arc, /// The format currently awaiting a `FormatDataResponse`, if any. pending_paste: Option, /// When `pending_paste` was issued, per [`CliprdrBackend::now_ms`]. @@ -140,6 +283,12 @@ impl_as_any!(AgentCliprdrBackend); /// /// Shared by [`AgentCliprdrBackend::on_request_format_list`] and the daemon's `clipboard_set*` /// handlers, which advertise immediately to an already-connected session. +/// +/// Does not cover [`ClipboardContent::Files`]: files use the separate +/// `ClipboardMessage::SendInitiateFileCopy` path (`Cliprdr::initiate_file_copy`), not +/// `SendInitiateCopy`, since offering a file list is `CLIPRDR`'s delayed-render file-list +/// mechanism rather than a `FormatDataRequest`-served format. Both call sites branch on +/// `ClipboardContent::Files` before reaching this function. pub(crate) fn advertised_formats(content: &ClipboardContent) -> Vec { match content { ClipboardContent::Text(_) => vec![ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)], @@ -150,10 +299,32 @@ pub(crate) fn advertised_formats(content: &ClipboardContent) -> Vec vec![ClipboardFormat::new(html_format_id()).with_name(ClipboardFormatName::HTML)], + ClipboardContent::Files(_) => Vec::new(), } } impl AgentCliprdrBackend { + /// Aborts the active fetch if its lock is among `clip_data_ids`, since it can no longer + /// safely continue against a remote clipboard that has changed underneath it. `reason` + /// distinguishes why the lock is gone (expiry timeout vs. immediate release by a new + /// `clipboard-set-files`) in the log line; the abort-and-notify behavior is identical either + /// way. Shared by `on_outgoing_locks_expired` and `on_outgoing_locks_cleared`. + fn abort_fetch_for_released_lock(&mut self, clip_data_ids: &[LockDataId], reason: &str) { + let mut state = self.state.lock().expect("clipboard state poisoned"); + let Some(active_id) = state.active_fetch_lock_id else { + return; + }; + if clip_data_ids.iter().any(|id| id.0 == active_id) { + debug!( + clip_data_id = active_id, + "Active file fetch's lock {reason}; aborting rather than continue against a changed remote clipboard" + ); + state.abort_active_fetch(); + drop(state); + self.file_fetch_notify.notify_waiters(); + } + } + /// Marks `paste` as the outstanding request and sends it. Only ever call this when no other /// request is outstanding (`pending_paste` is `None`), which `on_remote_copy` and /// `on_format_data_response` are responsible for maintaining. @@ -164,6 +335,90 @@ impl AgentCliprdrBackend { self.proxy .send_clipboard_message(ClipboardMessage::SendInitiatePaste(format)); } + + /// Builds the response to one `FileContentsRequest` for a file we offered, reading from the + /// local disk path `clipboard_set_files` recorded alongside the offered `FileDescriptor`. + fn build_file_contents_response(&self, request: &FileContentsRequest) -> FileContentsResponse<'static> { + use std::io::{Read as _, Seek as _, SeekFrom}; + + let state = self.state.lock().expect("clipboard state poisoned"); + let index = match usize::try_from(request.index) { + Ok(index) => index, + Err(_) => return FileContentsResponse::new_error(request.stream_id), + }; + + // MS-RDPECLIP 3.1.5.4.6: if the request carries a clipDataId, it MUST be serviced from + // the File Stream data locked under that id, not whatever is currently offered — the + // local offer may have been replaced by a later clipboard-set-files call while the lock + // (and the remote's in-flight fetch against it) is still active. + let (files, paths) = match request.data_id { + Some(id) => match state.local_locked_lists.get(&id) { + Some((files, paths)) => (files, paths), + // A present clipDataId names a snapshot this backend either never captured (the + // cache was full, see MAX_LOCAL_LOCKED_LISTS) or already reaped (the inactivity + // sweep's on_unlock): serving the current offer here would risk mixing content + // the lock exists specifically to prevent, so this is an error, not a fallback. + None => return FileContentsResponse::new_error(request.stream_id), + }, + None => { + let Some(ClipboardContent::Files(files)) = state.local.as_ref() else { + return FileContentsResponse::new_error(request.stream_id); + }; + (files, &state.local_file_paths) + } + }; + let (Some(descriptor), Some(path)) = (files.get(index), paths.get(index)) else { + return FileContentsResponse::new_error(request.stream_id); + }; + if descriptor + .attributes + .is_some_and(|attributes| attributes.contains(ironrdp_cliprdr::pdu::ClipboardFileAttributes::DIRECTORY)) + { + debug!(index, "File contents requested for a directory entry; refusing"); + return FileContentsResponse::new_error(request.stream_id); + } + let path = path.clone(); + drop(state); + + if request.flags.contains(FileContentsFlags::SIZE) { + return match std::fs::metadata(&path) { + Ok(metadata) => FileContentsResponse::new_size_response(request.stream_id, metadata.len()), + Err(error) => { + debug!(%error, index, "Failed to stat offered file for a SIZE request"); + FileContentsResponse::new_error(request.stream_id) + } + }; + } + + // RANGE. Cap the read below the caller's own `requested_size`: MS-RDPECLIP defines + // `cbRequested` as an upper bound on what a responder may return, not a guarantee of how + // much it will, so a peer asking for an unreasonably large single chunk gets a shorter + // response instead of forcing a matching allocation here; `ChunkedFetch` on the requesting + // side already handles a response shorter than requested by issuing another RANGE request + // for the remainder. + const MAX_RESPONSE_CHUNK_BYTES: u32 = 4 * 1024 * 1024; + let read_len = request.requested_size.min(MAX_RESPONSE_CHUNK_BYTES); + + let mut file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(error) => { + debug!(%error, index, "Failed to open offered file for a RANGE request"); + return FileContentsResponse::new_error(request.stream_id); + } + }; + if let Err(error) = file.seek(SeekFrom::Start(request.position)) { + debug!(%error, index, position = request.position, "Failed to seek offered file"); + return FileContentsResponse::new_error(request.stream_id); + } + let mut buf = Vec::new(); + match file.take(u64::from(read_len)).read_to_end(&mut buf) { + Ok(_) => FileContentsResponse::new_data_response(request.stream_id, buf), + Err(error) => { + debug!(%error, index, "Failed to read offered file"); + FileContentsResponse::new_error(request.stream_id) + } + } + } } impl CliprdrBackend for AgentCliprdrBackend { @@ -172,30 +427,88 @@ impl CliprdrBackend for AgentCliprdrBackend { } fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { - // No file transfer support: no STREAM_FILECLIP_ENABLED, no CAN_LOCK_CLIPDATA. - ClipboardGeneralCapabilityFlags::empty() + ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED | ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA } - fn on_ready(&mut self) {} + fn on_ready(&mut self) { + // A stored file offer is re-advertised here rather than in `on_request_format_list`: + // `Cliprdr::initiate_file_copy` requires the Ready state, and the format list sent from + // `on_request_format_list` is what completes the handshake that reaches it. + let state = self.state.lock().expect("clipboard state poisoned"); + if let Some(ClipboardContent::Files(files)) = state.local.as_ref() { + if state + .negotiated_capabilities + .contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + { + let files = files.clone(); + drop(state); + self.proxy + .send_clipboard_message(ClipboardMessage::SendInitiateFileCopy(files)); + } + } + } fn on_request_format_list(&mut self) { - let formats = match self.state.lock().expect("clipboard state poisoned").local.as_ref() { - Some(content) => advertised_formats(content), - None => Vec::new(), - }; - self.proxy - .send_clipboard_message(ClipboardMessage::SendInitiateCopy(formats)); + let state = self.state.lock().expect("clipboard state poisoned"); + match state.local.as_ref() { + Some(ClipboardContent::Files(_)) => { + // `initiate_file_copy` hard-errors outside the Ready state and the error is + // session-fatal, so the handshake completes with an empty list and `on_ready` + // re-advertises the files. + if !state + .negotiated_capabilities + .contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + { + debug!("Not re-advertising local file list: file transfer was not negotiated"); + } + drop(state); + self.proxy + .send_clipboard_message(ClipboardMessage::SendInitiateCopy(Vec::new())); + } + Some(content) => { + let formats = advertised_formats(content); + drop(state); + self.proxy + .send_clipboard_message(ClipboardMessage::SendInitiateCopy(formats)); + } + None => { + drop(state); + self.proxy + .send_clipboard_message(ClipboardMessage::SendInitiateCopy(Vec::new())); + } + } } fn on_process_negotiated_capabilities(&mut self, capabilities: ClipboardGeneralCapabilityFlags) { debug!(?capabilities, "CLIPRDR capabilities negotiated"); + self.state + .lock() + .expect("clipboard state poisoned") + .negotiated_capabilities = capabilities; } fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { // The remote clipboard changed: whatever content was cached no longer reflects it. - self.state.lock().expect("clipboard state poisoned").remote = None; + { + let mut state = self.state.lock().expect("clipboard state poisoned"); + state.remote = None; + state.remote_file_lock_id = None; + // Abandoning an in-progress fetch here without marking it failed would leave its + // waiting `clipboard_get_file` caller unable to tell its fetch was interrupted from a + // fresh one nobody has started yet: it would keep waiting until its own timeout, and + // a second `clipboard_get_file` call in the meantime would see `active_fetch: None` + // and start a new fetch, whose result the first caller's notified-but-stale wakeup + // could then read as its own. Failing it explicitly and waking any waiter now closes + // that window. + let aborted = state.abort_active_fetch(); + drop(state); + if aborted { + self.file_fetch_notify.notify_waiters(); + } + } - // Prefer the richest representation offered: image over HTML over text, DIBV5 over DIB. + // Prefer the richest representation offered: files over image over HTML over text, DIBV5 + // over DIB. let has_dibv5 = available_formats .iter() .any(|format| format.id() == ClipboardFormatId::CF_DIBV5); @@ -211,8 +524,16 @@ impl CliprdrBackend for AgentCliprdrBackend { let has_text = available_formats .iter() .any(|format| format.id() == ClipboardFormatId::CF_UNICODETEXT); + // The remote assigns its own ID to the registered file-list format; match by name and use + // whatever ID it picked, same reasoning as the HTML format elsewhere in this file. + let remote_file_list_id = available_formats + .iter() + .find(|format| format.name() == Some(&ClipboardFormatName::FILE_LIST)) + .map(ClipboardFormat::id); - let target = if has_dibv5 { + let target = if let Some(file_list_id) = remote_file_list_id { + Some((PendingPaste::Files, file_list_id)) + } else if has_dibv5 { Some((PendingPaste::Image { dibv5: true }, ClipboardFormatId::CF_DIBV5)) } else if has_dib { Some((PendingPaste::Image { dibv5: false }, ClipboardFormatId::CF_DIB)) @@ -316,6 +637,14 @@ impl CliprdrBackend for AgentCliprdrBackend { None } }, + // A successful file-list response is intercepted by `Cliprdr` itself and + // delivered via `on_remote_file_list`, never reaching here; this arm exists only + // for the fallback case where the response failed to parse as a file list, which + // `Cliprdr` forwards here with `is_error()` still false. + PendingPaste::Files => { + debug!("File list response could not be parsed; dropped"); + None + } }; if let Some(content) = content { self.state.lock().expect("clipboard state poisoned").remote = Some(content); @@ -331,18 +660,277 @@ impl CliprdrBackend for AgentCliprdrBackend { } fn on_file_contents_request(&mut self, request: FileContentsRequest) { - debug!(?request, "File contents request ignored: no file transfer support"); + let response = self.build_file_contents_response(&request); + self.proxy + .send_clipboard_message(ClipboardMessage::SendFileContentsResponse(response)); } fn on_file_contents_response(&mut self, response: FileContentsResponse<'_>) { - debug!(?response, "File contents response ignored: no file transfer support"); + let mut state = self.state.lock().expect("clipboard state poisoned"); + let Some(fetch) = state.active_fetch.as_mut() else { + debug!("File contents response received with no active fetch; dropped"); + return; + }; + if fetch.stream_id() != response.stream_id() { + debug!( + expected = fetch.stream_id(), + got = response.stream_id(), + "File contents response stream id mismatch; dropped" + ); + return; + } + + let progress = fetch.on_response(&response); + let next_request = if matches!(progress, ChunkedFetchProgress::InProgress) { + fetch.next_request() + } else { + None + }; + if matches!(progress, ChunkedFetchProgress::Complete | ChunkedFetchProgress::Failed) { + state.active_fetch_result = Some(progress); + state.active_fetch_result_stream_id = Some(response.stream_id()); + } + drop(state); + + if let Some(next) = next_request { + self.proxy + .send_clipboard_message(ClipboardMessage::SendFileContentsRequest(next)); + } + // Wake on every response, not just Complete/Failed: `clipboard_get_file`'s wait loop + // recomputes its deadline each iteration as an idle timeout, so an `InProgress` chunk + // must reset it too, or a transfer with healthy per-chunk progress can still exceed the + // fixed 60s if the loop is never woken until completion. + self.file_fetch_notify.notify_waiters(); } fn on_lock(&mut self, data_id: LockDataId) { - debug!(?data_id, "Clipboard lock ignored: no file transfer support"); + debug!(?data_id, "Remote locked local clipboard file list"); + let mut state = self.state.lock().expect("clipboard state poisoned"); + let Some(ClipboardContent::Files(files)) = state.local.clone() else { + return; + }; + let paths = state.local_file_paths.clone(); + if state.local_locked_lists.len() >= MAX_LOCAL_LOCKED_LISTS { + debug!(?data_id, "Local locked-list cache full; not snapshotting this lock"); + return; + } + // `Cliprdr` keeps its own snapshot for index-bounds validation but does not expose it to + // the backend, so this snapshot is what makes `build_file_contents_response` serve the + // locked content rather than whatever `local`/`local_file_paths` hold by the time a + // request against this id actually arrives. + state.local_locked_lists.insert(data_id.0, (files, paths)); } fn on_unlock(&mut self, data_id: LockDataId) { - debug!(?data_id, "Clipboard unlock ignored: no file transfer support"); + debug!(?data_id, "Remote unlocked local clipboard file list"); + self.state + .lock() + .expect("clipboard state poisoned") + .local_locked_lists + .remove(&data_id.0); + } + + fn on_remote_file_list(&mut self, files: &[FileDescriptor], clip_data_id: Option) { + debug!(file_count = files.len(), ?clip_data_id, "Received remote file list"); + let mut state = self.state.lock().expect("clipboard state poisoned"); + state.remote = Some(ClipboardContent::Files(files.to_vec())); + state.remote_file_lock_id = clip_data_id; + + // Success for a `PendingPaste::Files` request arrives here, not through + // `on_format_data_response`, so this callback is what actually clears it. + self.pending_paste = None; + self.pending_since_ms = None; + if let Some(target) = self.desired_paste.take() { + drop(state); + self.issue_paste(target); + } + } + + fn on_outgoing_locks_expired(&mut self, clip_data_ids: &[LockDataId]) { + self.abort_fetch_for_released_lock(clip_data_ids, "expired"); + } + + fn on_outgoing_locks_cleared(&mut self, clip_data_ids: &[LockDataId]) { + // `initiate_file_copy` (a new `clipboard-set-files`) releases outgoing locks immediately + // rather than waiting for the expiry timeout, but an active fetch bound to one of those + // locks needs the same abort `on_outgoing_locks_expired` already gives the timeout path: + // otherwise it keeps issuing FileContentsRequests against a data_id whose lock no longer + // exists, against a remote clipboard that has changed underneath it. + self.abort_fetch_for_released_lock(clip_data_ids, "released"); + } +} + +#[cfg(test)] +mod tests { + use core::sync::atomic::{AtomicU32, Ordering}; + use core::time::Duration; + use std::path::PathBuf; + use std::sync::{Arc, Mutex}; + + use ironrdp_client::rdp::RdpInputSender; + use ironrdp_cliprdr::backend::CliprdrBackend as _; + use ironrdp_cliprdr::chunked_fetch::{ChunkedFetch, ChunkedFetchProgress}; + use ironrdp_cliprdr::pdu::{ + FileContentsFlags, FileContentsRequest, FileContentsResponse, FileDescriptor, LockDataId, + }; + + use super::{ + AgentClipboardMessageProxy, AgentCliprdrBackend, ClipboardContent, ClipboardState, advertised_formats, + }; + + #[test] + fn advertised_formats_is_empty_for_files() { + // Files use `SendInitiateFileCopy`, not `SendInitiateCopy`; both call sites branch on + // `ClipboardContent::Files` before reaching `advertised_formats`, so this is defensive + // documentation of that invariant rather than a path either call site actually takes. + let formats = advertised_formats(&ClipboardContent::Files(Vec::new())); + assert!(formats.is_empty()); + } + + fn test_backend() -> AgentCliprdrBackend { + let (sender, _input_rx) = RdpInputSender::channel(1); + AgentCliprdrBackend { + state: Arc::new(Mutex::new(ClipboardState::default())), + proxy: AgentClipboardMessageProxy(sender), + file_fetch_notify: Arc::new(tokio::sync::Notify::new()), + pending_paste: None, + pending_since_ms: None, + desired_paste: None, + } + } + + fn temp_file_with(label: &str, content: &[u8]) -> PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("ironrdp-daemon-test-{}-{unique}-{label}", std::process::id())); + std::fs::write(&path, content).expect("write temp test file"); + path + } + + fn range_request(data_id: Option) -> FileContentsRequest { + FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::RANGE, + position: 0, + requested_size: 1024, + data_id, + } + } + + #[test] + fn file_contents_request_with_lock_serves_the_locked_snapshot_not_the_current_offer() { + let mut backend = test_backend(); + let locked_path = temp_file_with("locked", b"LOCKED_CONTENT"); + let replaced_path = temp_file_with("replaced", b"REPLACED_CONTENT"); + + { + let mut state = backend.state.lock().unwrap(); + state.local = Some(ClipboardContent::Files(vec![FileDescriptor::new("locked.txt")])); + state.local_file_paths = vec![locked_path.clone()]; + } + + // The remote locks our offered list under clipDataId 42 before fetching from it. + backend.on_lock(LockDataId(42)); + + // clipboard-set-files replaces the offer while the lock is still active. + { + let mut state = backend.state.lock().unwrap(); + state.local = Some(ClipboardContent::Files(vec![FileDescriptor::new("replaced.txt")])); + state.local_file_paths = vec![replaced_path.clone()]; + } + + // A request against the locked id must still read the locked file's bytes, not the + // replacement's. + let response = backend.build_file_contents_response(&range_request(Some(42))); + assert_eq!(response.data(), b"LOCKED_CONTENT"); + + // Once unlocked, the same clipDataId has no snapshot left; a request naming it errors + // rather than falling back to whatever is currently offered, per MS-RDPECLIP 3.1.5.4.6. + backend.on_unlock(LockDataId(42)); + let response = backend.build_file_contents_response(&range_request(Some(42))); + assert!(response.is_error()); + + let _ = std::fs::remove_file(&locked_path); + let _ = std::fs::remove_file(&replaced_path); + } + + #[test] + fn file_contents_request_without_lock_serves_the_current_offer() { + let backend = test_backend(); + let path = temp_file_with("unlocked", b"CURRENT_CONTENT"); + { + let mut state = backend.state.lock().unwrap(); + state.local = Some(ClipboardContent::Files(vec![FileDescriptor::new("current.txt")])); + state.local_file_paths = vec![path.clone()]; + } + + let response = backend.build_file_contents_response(&range_request(None)); + assert_eq!(response.data(), b"CURRENT_CONTENT"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn outgoing_locks_cleared_aborts_the_active_fetch_bound_to_them() { + // A new clipboard-set-files releases outgoing locks immediately (not just on the + // inactivity-timeout path `on_outgoing_locks_expired` already covered), so an active + // fetch bound to one of those locks must be aborted the same way. + let mut backend = test_backend(); + { + let mut state = backend.state.lock().unwrap(); + state.active_fetch = Some(ChunkedFetch::new_with_size_query(1, 0, 4096, Some(7), u64::MAX)); + state.active_fetch_lock_id = Some(7); + } + + backend.on_outgoing_locks_cleared(&[LockDataId(7)]); + + let state = backend.state.lock().unwrap(); + assert!(state.active_fetch.is_none()); + assert!(state.active_fetch_lock_id.is_none()); + assert!(matches!(state.active_fetch_result, Some(ChunkedFetchProgress::Failed))); + } + + #[test] + fn outgoing_locks_cleared_ignores_unrelated_ids() { + let mut backend = test_backend(); + { + let mut state = backend.state.lock().unwrap(); + state.active_fetch_lock_id = Some(7); + } + + backend.on_outgoing_locks_cleared(&[LockDataId(99)]); + + let state = backend.state.lock().unwrap(); + assert_eq!(state.active_fetch_lock_id, Some(7)); + assert!(state.active_fetch_result.is_none()); + } + + #[tokio::test] + async fn in_progress_response_wakes_the_fetch_waiter() { + // Regression test: a mid-transfer chunk must wake `clipboard_get_file`'s waiter too, not + // only the final Complete/Failed response, or the daemon's idle-timeout loop is never + // reset by real chunk arrivals and can time out a healthy, still-progressing transfer. + let mut backend = test_backend(); + { + let mut state = backend.state.lock().unwrap(); + // total_size=8, chunk_size=4: the first response leaves the fetch InProgress. + state.active_fetch = Some(ChunkedFetch::new(1, 0, 8, 4, None, u64::MAX)); + } + + let notify = Arc::clone(&backend.file_fetch_notify); + let waiter = tokio::spawn(async move { notify.notified().await }); + tokio::task::yield_now().await; + + let response = FileContentsResponse::new_data_response(1, vec![0u8; 4]); + backend.on_file_contents_response(response); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("an in-progress response must wake the fetch waiter") + .expect("waiter task must not panic"); + + let state = backend.state.lock().unwrap(); + assert_eq!(state.active_fetch.as_ref().map(ChunkedFetch::is_finished), Some(false)); } } diff --git a/crates/ironrdp-daemon/src/daemon.rs b/crates/ironrdp-daemon/src/daemon.rs index b4a325192c..2caecb1de9 100644 --- a/crates/ironrdp-daemon/src/daemon.rs +++ b/crates/ironrdp-daemon/src/daemon.rs @@ -21,6 +21,8 @@ use ironrdp_client::rdp::{ RdpOutputEvent, }; use ironrdp_cliprdr::backend::ClipboardMessage; +use ironrdp_cliprdr::chunked_fetch::{ChunkedFetch, ChunkedFetchProgress}; +use ironrdp_cliprdr::pdu::{ClipboardFileAttributes, ClipboardGeneralCapabilityFlags, FileDescriptor}; use ironrdp_input::{Database, MousePosition, Operation, Scancode, WheelRotations}; use ironrdp_pdu::rdp::capability_sets::MajorPlatformType; use ironrdp_propertyset::{PropertySet, Value}; @@ -37,10 +39,11 @@ use std::collections::BTreeSet; use ironrdp_rdpdr_native::{RedirectedDrive, WindowsRdpdrBackendFactory}; use crate::ipc::{ - ConnState, KeyFilter, MAX_RAIL_RETAINED_EVENTS, MAX_UNICODE_TEXT_CHARS, NowDiagnostics, Payload, PenFrameRequest, - PropValue, PropertyDump, PropertyEntry, RailEvent, RailEventDump, RailEventKind, RailExecuteFailureReason, - RailExecuteRequest, RailLaunchInfo, RailStatusInfo, Request, Response, StatusInfo, TouchFrameRequest, - pen_event_from_request, touch_event_from_request, + ClipboardFileEntry, ConnState, KeyFilter, MAX_CLIPBOARD_FILE_BYTES, MAX_CLIPBOARD_FILE_LIST_ENTRIES, + MAX_RAIL_RETAINED_EVENTS, MAX_UNICODE_TEXT_CHARS, NowDiagnostics, Payload, PenFrameRequest, PropValue, + PropertyDump, PropertyEntry, RailEvent, RailEventDump, RailEventKind, RailExecuteFailureReason, RailExecuteRequest, + RailLaunchInfo, RailStatusInfo, Request, Response, StatusInfo, TouchFrameRequest, pen_event_from_request, + touch_event_from_request, }; use crate::logbuf::{self, LogBuffer}; use crate::now::NowEndpoint; @@ -272,10 +275,67 @@ struct Session { rail_enabled: bool, live: Arc>, rail_notify: Arc, + /// Woken by the `CLIPRDR` backend whenever an in-progress `clipboard_get_file` fetch + /// advances or finishes; see `crate::clipboard::AgentCliprdrBackend`. + clipboard_file_notify: Arc, now_endpoint: Arc, operations: OperationManager, } +/// Converts a [`std::time::SystemTime`] to a Windows FILETIME (100-nanosecond intervals since +/// 1601-01-01), for [`FileDescriptor::with_last_write_time`]. +/// +/// Saturates rather than panics on a time far enough in the future to overflow; a wrong-but-huge +/// last-write-time is a display nit; a panic on a legitimate file is not. +fn system_time_to_filetime(time: std::time::SystemTime) -> u64 { + /// Seconds between the FILETIME epoch (1601-01-01) and the Unix epoch (1970-01-01). + const EPOCH_DIFFERENCE_SECS: u64 = 11_644_473_600; + match time.duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => { + let secs = duration.as_secs().saturating_add(EPOCH_DIFFERENCE_SECS); + secs.saturating_mul(10_000_000) + .saturating_add(u64::from(duration.subsec_nanos()) / 100) + } + // Before the Unix epoch: not expected for a real file; encode as "unknown" via 0 rather + // than guess. + Err(_) => 0, + } +} + +/// Converts a Windows FILETIME back to Unix seconds, for display in `Payload::ClipboardFileList`. +/// +/// `None` if the value predates the Unix epoch (a remote-controlled field; treat as absent rather +/// than let the subtraction wrap). +fn filetime_to_unix_secs(filetime: u64) -> Option { + const EPOCH_DIFFERENCE_SECS: u64 = 11_644_473_600; + (filetime / 10_000_000).checked_sub(EPOCH_DIFFERENCE_SECS) +} + +/// Takes `active_fetch_result` if `clipboard_get_file`'s fetch has finished, materializing the +/// response and clearing the (now-finished) fetch's slot. `None` means the fetch has not yet +/// resolved (or resolved for a different `stream_id` than the caller's own) and the caller should +/// keep waiting. Shared by the wait loop's per-iteration check and its post-timeout recheck in +/// `Daemon::clipboard_get_file`, which must resolve a same-instant race identically rather than +/// risk the two copies drifting apart. +/// +/// Guarded by `stream_id` rather than taking whatever `active_fetch_result` holds: an aborted +/// fetch clears `active_fetch` in the same step it sets `active_fetch_result`, so a later, +/// unrelated fetch can start and finish in the same slot before the first call's own waiter gets +/// scheduled. Without this guard that waiter would consume the later fetch's bytes. +fn take_finished_fetch_result(clipboard: &mut crate::clipboard::ClipboardState, stream_id: u32) -> Option { + if clipboard.active_fetch_result_stream_id != Some(stream_id) { + return None; + } + let result = clipboard.active_fetch_result.take()?; + clipboard.active_fetch_result_stream_id = None; + let fetch = clipboard.active_fetch.take(); + clipboard.active_fetch_lock_id = None; + Some(match (result, fetch) { + (ChunkedFetchProgress::Complete, Some(fetch)) => Response::Ok(Payload::ClipboardFile(fetch.into_data())), + _ => Response::typed_error(crate::ipc::AgentErrorCategory::Internal, "file fetch failed"), + }) +} + fn enqueue_unicode_text(input_tx: &RdpInputSender, input_db: &mut Database, text: &str) -> Response { // Reserve every queue slot before changing keyboard state. A full queue therefore sends no // prefix of the requested text. @@ -582,6 +642,9 @@ impl Daemon { Request::ClipboardSetImage { png } => DaemonResponse::Single(self.clipboard_set_image(png)), Request::ClipboardGetHtml => DaemonResponse::Single(self.clipboard_get_html()), Request::ClipboardSetHtml { html } => DaemonResponse::Single(self.clipboard_set_html(html)), + Request::ClipboardSetFiles { paths } => DaemonResponse::Single(self.clipboard_set_files(paths)), + Request::ClipboardListFiles => DaemonResponse::Single(self.clipboard_list_files()), + Request::ClipboardGetFile { index } => DaemonResponse::Single(self.clipboard_get_file(index).await), Request::MouseMove { x, y } => { DaemonResponse::Single(self.input(Operation::MouseMove(MousePosition { x, y }))) } @@ -789,9 +852,37 @@ impl Daemon { None => client, }; let input_tx = client.input_sender(); + let clipboard_file_notify = Arc::new(tokio::sync::Notify::new()); + // State left over from an abruptly-ended previous session is meaningless against this + // new one, and the remote file list plus its lock id are actively dangerous to keep: a + // `clipboard_get_file` call between sessions could otherwise fetch against a + // `remote_file_lock_id` that names a lock on a `CLIPRDR` channel that no longer exists. + // `local`/`local_file_paths` are deliberately left alone: an offer made before or between + // sessions is meant to survive and be advertised on the next connection. + { + let mut clipboard = self.clipboard.lock().expect("clipboard state poisoned"); + clipboard.remote = None; + clipboard.remote_file_lock_id = None; + clipboard.negotiated_capabilities = ClipboardGeneralCapabilityFlags::empty(); + // Marks the outgoing fetch Failed (keyed by its own stream_id) rather than silently + // clearing it: a waiter woken just below must actually resolve promptly through + // `take_finished_fetch_result`, not find nothing and fall through to its own 60s + // FETCH_TIMEOUT despite being woken here specifically to avoid that. + clipboard.abort_active_fetch(); + } + // Wake a `clipboard_get_file` waiter left over from the outgoing session (if any) so it + // re-checks promptly instead of running out its own FETCH_TIMEOUT: its fetch was just + // cleared above, and without this it would otherwise only find out at its own deadline + // (see the stream_id-guarded clear in `clipboard_get_file`'s timeout path, which this + // pairs with: this is what lets that waiter wake up and leave promptly rather than + // silently sitting on a slot the next session has already reused). + if let Some(outgoing_session) = self.state.lock().expect("daemon state poisoned").as_ref() { + outgoing_session.clipboard_file_notify.notify_waiters(); + } let client = client.with_cliprdr_backend_factory(Box::new(crate::clipboard::AgentCliprdrBackendFactory::new( Arc::clone(&self.clipboard), input_tx.clone(), + Arc::clone(&clipboard_file_notify), ))); let rail_notify = Arc::new(tokio::sync::Notify::new()); @@ -849,6 +940,7 @@ impl Daemon { rail_enabled, live, rail_notify, + clipboard_file_notify, operations: OperationManager::new(Arc::clone(&now_endpoint)), now_endpoint, }); @@ -1108,9 +1200,9 @@ impl Daemon { /// Returns the last text received from the remote clipboard, if any. /// - /// `None` when nothing has been received yet, or when the last remote copy was an image or - /// HTML, not text; the cases are indistinguishable from this call alone. Use - /// `clipboard_get_image` or `clipboard_get_html` for those cases. + /// `None` when nothing has been received yet, or when the last remote copy was an image, HTML, + /// or files, not text; the cases are indistinguishable from this call alone. Use + /// `clipboard_get_image`, `clipboard_get_html`, or `clipboard_list_files` for those cases. /// /// # Panics /// @@ -1118,9 +1210,12 @@ impl Daemon { fn clipboard_get(&self) -> Response { let text = match self.clipboard.lock().expect("clipboard state poisoned").remote.clone() { Some(crate::clipboard::ClipboardContent::Text(text)) => Some(text), - Some(crate::clipboard::ClipboardContent::Image(_) | crate::clipboard::ClipboardContent::Html(_)) | None => { - None - } + Some( + crate::clipboard::ClipboardContent::Image(_) + | crate::clipboard::ClipboardContent::Html(_) + | crate::clipboard::ClipboardContent::Files(_), + ) + | None => None, }; Response::Ok(Payload::ClipboardText(text)) } @@ -1140,8 +1235,9 @@ impl Daemon { /// Returns the last image received from the remote clipboard as PNG bytes, if any. /// - /// `None` when nothing has been received yet, or when the last remote copy was text or HTML, - /// not an image. Use `clipboard_get` or `clipboard_get_html` for those cases. + /// `None` when nothing has been received yet, or when the last remote copy was text, HTML, or + /// files, not an image. Use `clipboard_get`, `clipboard_get_html`, or `clipboard_list_files` + /// for those cases. /// /// # Panics /// @@ -1149,9 +1245,12 @@ impl Daemon { fn clipboard_get_image(&self) -> Response { let png = match self.clipboard.lock().expect("clipboard state poisoned").remote.clone() { Some(crate::clipboard::ClipboardContent::Image(png)) => Some(png), - Some(crate::clipboard::ClipboardContent::Text(_) | crate::clipboard::ClipboardContent::Html(_)) | None => { - None - } + Some( + crate::clipboard::ClipboardContent::Text(_) + | crate::clipboard::ClipboardContent::Html(_) + | crate::clipboard::ClipboardContent::Files(_), + ) + | None => None, }; Response::Ok(Payload::ClipboardImage(png)) } @@ -1179,8 +1278,8 @@ impl Daemon { /// Returns the last HTML fragment received from the remote clipboard, if any. /// - /// `None` both when nothing has been received yet and when the last remote copy was text or - /// an image, not HTML. + /// `None` when nothing has been received yet, or when the last remote copy was text, an image, + /// or files, not HTML. /// /// # Panics /// @@ -1188,9 +1287,12 @@ impl Daemon { fn clipboard_get_html(&self) -> Response { let html = match self.clipboard.lock().expect("clipboard state poisoned").remote.clone() { Some(crate::clipboard::ClipboardContent::Html(html)) => Some(html), - Some(crate::clipboard::ClipboardContent::Text(_) | crate::clipboard::ClipboardContent::Image(_)) | None => { - None - } + Some( + crate::clipboard::ClipboardContent::Text(_) + | crate::clipboard::ClipboardContent::Image(_) + | crate::clipboard::ClipboardContent::Files(_), + ) + | None => None, }; Response::Ok(Payload::ClipboardHtml(html)) } @@ -1234,6 +1336,278 @@ impl Daemon { } } + /// Offers local files to the remote via the `CLIPRDR` file-list mechanism, replacing any + /// other local clipboard content. + /// + /// Each path must name a single regular file: a directory is rejected outright rather than + /// silently skipped or partially handled, since this daemon does not (yet) support recursive + /// folder copy. Unlike `clipboard_set`/`clipboard_set_image`, a connected session that has not + /// negotiated file transfer support fails the whole call instead of storing the offer anyway: + /// `Cliprdr::initiate_file_copy` itself hard-errors without `STREAM_FILECLIP_ENABLED`, and + /// that error is session-fatal by the time it reaches `ironrdp-client`'s dispatcher, so this + /// must be caught here rather than risk sending the message at all. + /// + /// # Panics + /// + /// Panics if the clipboard or daemon state mutex is poisoned. + fn clipboard_set_files(&self, paths: Vec) -> Response { + if paths.is_empty() { + return Response::typed_error(crate::ipc::AgentErrorCategory::InvalidRequest, "no files given"); + } + if paths.len() > MAX_CLIPBOARD_FILE_LIST_ENTRIES { + return Response::typed_error(crate::ipc::AgentErrorCategory::InvalidRequest, "too many files"); + } + + let mut descriptors = Vec::with_capacity(paths.len()); + let mut local_paths = Vec::with_capacity(paths.len()); + for path in paths { + let path = PathBuf::from(path); + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + return Response::typed_error( + crate::ipc::AgentErrorCategory::InvalidRequest, + format!("{}: {error}", path.display()), + ); + } + }; + if !metadata.is_file() { + return Response::typed_error( + crate::ipc::AgentErrorCategory::InvalidRequest, + format!( + "{}: not a regular file (directories are not supported, name individual files)", + path.display() + ), + ); + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return Response::typed_error( + crate::ipc::AgentErrorCategory::InvalidRequest, + format!("{}: not a valid file name", path.display()), + ); + }; + let mut descriptor = FileDescriptor::new(name) + .with_attributes(ClipboardFileAttributes::NORMAL) + .with_file_size(metadata.len()); + if let Ok(modified) = metadata.modified() { + descriptor = descriptor.with_last_write_time(system_time_to_filetime(modified)); + } + descriptors.push(descriptor); + local_paths.push(path); + } + + let mut clipboard = self.clipboard.lock().expect("clipboard state poisoned"); + if let Some(session) = self.state.lock().expect("daemon state poisoned").as_ref() { + if !clipboard + .negotiated_capabilities + .contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + { + return Response::typed_error( + crate::ipc::AgentErrorCategory::Unavailable, + "the connected session's server does not support file transfer", + ); + } + clipboard.local = Some(crate::clipboard::ClipboardContent::Files(descriptors.clone())); + clipboard.local_file_paths = local_paths; + let _ = session + .input_tx + .send_clipboard(ClipboardMessage::SendInitiateFileCopy(descriptors)); + } else { + clipboard.local = Some(crate::clipboard::ClipboardContent::Files(descriptors)); + clipboard.local_file_paths = local_paths; + } + Response::ok() + } + + /// Lists the remote's currently offered files, if any. Metadata only: nothing is fetched. + /// + /// # Panics + /// + /// Panics if the clipboard state mutex is poisoned. + fn clipboard_list_files(&self) -> Response { + let clipboard = self.clipboard.lock().expect("clipboard state poisoned"); + let files = match clipboard.remote.as_ref() { + Some(crate::clipboard::ClipboardContent::Files(files)) => Some( + files + .iter() + .map(|descriptor| ClipboardFileEntry { + name: descriptor.name.clone(), + relative_path: descriptor.relative_path.clone(), + is_directory: descriptor + .attributes + .is_some_and(|attributes| attributes.contains(ClipboardFileAttributes::DIRECTORY)), + size: descriptor.file_size, + last_write_time: descriptor.last_write_time.and_then(filetime_to_unix_secs), + }) + .collect(), + ), + _ => None, + }; + Response::Ok(Payload::ClipboardFileList(files)) + } + + /// Fetches one file's full contents from the remote by its position in the last file list + /// `clipboard_list_files` returned, bounded at `MAX_CLIPBOARD_FILE_BYTES`. + /// + /// Drives `ChunkedFetch` to completion, issuing successive `FileContentsRequest`s and waiting + /// on `Session::clipboard_file_notify` for `AgentCliprdrBackend::on_file_contents_response` to + /// advance it, the same wait-and-recheck shape `rail_wait` uses for RAIL evidence. + /// + /// # Panics + /// + /// Panics if the clipboard or daemon state mutex is poisoned. + async fn clipboard_get_file(&self, index: i32) -> Response { + // Idle timeout, not a total-transfer budget: recomputed on every loop iteration below so + // it resets on each chunk's progress, matching `Cliprdr`'s own per-request + // `transfer_timeout`. A fixed total-transfer deadline would time out a large file over a + // slow-but-healthy link even though each individual chunk arrives well within its own + // allowance. + const FETCH_TIMEOUT: Duration = Duration::from_secs(60); + + // `clipboard_set_files` and `set_local_and_advertise` lock `clipboard` before `state`; + // taking the two in the opposite order here would deadlock against either racing on + // another IPC connection. Clone what this function needs from the session while `state` + // is held, then release it before `clipboard` is ever locked. + let (input_tx, clipboard_file_notify) = { + let guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_ref() else { + return Response::typed_error(crate::ipc::AgentErrorCategory::Unavailable, "no active session"); + }; + (session.input_tx.clone(), Arc::clone(&session.clipboard_file_notify)) + }; + + let (notify, stream_id) = { + let mut clipboard = self.clipboard.lock().expect("clipboard state poisoned"); + if !clipboard + .negotiated_capabilities + .contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + { + return Response::typed_error( + crate::ipc::AgentErrorCategory::Unavailable, + "the connected session's server does not support file transfer", + ); + } + if clipboard.active_fetch.is_some() { + return Response::typed_error( + crate::ipc::AgentErrorCategory::Conflict, + "another clipboard file fetch is already in progress", + ); + } + let Some(crate::clipboard::ClipboardContent::Files(files)) = clipboard.remote.as_ref() else { + return Response::typed_error( + crate::ipc::AgentErrorCategory::Unavailable, + "the remote clipboard has no files", + ); + }; + let Ok(list_index) = usize::try_from(index) else { + return Response::typed_error( + crate::ipc::AgentErrorCategory::InvalidRequest, + "index must not be negative", + ); + }; + let Some(descriptor) = files.get(list_index) else { + return Response::typed_error(crate::ipc::AgentErrorCategory::InvalidRequest, "index out of range"); + }; + if descriptor + .attributes + .is_some_and(|attributes| attributes.contains(ClipboardFileAttributes::DIRECTORY)) + { + return Response::typed_error( + crate::ipc::AgentErrorCategory::InvalidRequest, + "index names a directory, not a file", + ); + } + + // A declared zero-size file is legitimately already complete: ChunkedFetch::new + // starts it in the Complete state with an empty buffer, so its next_request() is + // None immediately, indistinguishable from the oversized-Failed case below by that + // signal alone. Short-circuit here instead of letting it fall into the "exceeds the + // transport limit" branch, which would misreport an empty file as too large. + let file_size = descriptor.file_size; + if file_size == Some(0) { + return Response::Ok(Payload::ClipboardFile(Vec::new())); + } + + let clip_data_id = clipboard.remote_file_lock_id; + let max_total_size = u64::try_from(MAX_CLIPBOARD_FILE_BYTES).unwrap_or(u64::MAX); + let stream_id = clipboard.next_file_stream_id(); + let mut fetch = match file_size { + Some(size) => ChunkedFetch::new( + stream_id, + index, + size, + crate::clipboard::FILE_FETCH_CHUNK_SIZE, + clip_data_id, + max_total_size, + ), + None => ChunkedFetch::new_with_size_query( + stream_id, + index, + crate::clipboard::FILE_FETCH_CHUNK_SIZE, + clip_data_id, + max_total_size, + ), + }; + // Past the zero-size short-circuit above, a None here can only be the oversized-Failed + // case (a same-call SIZE query completing synchronously is not a real path: `Fetching` + // and `AwaitingSize` both always have a request to issue next). + let Some(first_request) = fetch.next_request() else { + return Response::typed_error( + crate::ipc::AgentErrorCategory::InvalidRequest, + "file exceeds the clipboard RPC transport limit", + ); + }; + + clipboard.active_fetch = Some(fetch); + clipboard.active_fetch_lock_id = clip_data_id; + clipboard.active_fetch_result = None; + clipboard.active_fetch_result_stream_id = None; + let _ = input_tx.send_clipboard(ClipboardMessage::SendFileContentsRequest(first_request)); + (clipboard_file_notify, stream_id) + }; + + loop { + // Recomputed each iteration: an idle timeout since the last progress notification, + // not a fixed budget for the whole (possibly multi-chunk) transfer. + let deadline = tokio::time::Instant::now() + FETCH_TIMEOUT; + let notified = notify.notified(); + tokio::pin!(notified); + let _ = notified.as_mut().enable(); + + { + let mut clipboard = self.clipboard.lock().expect("clipboard state poisoned"); + if let Some(response) = take_finished_fetch_result(&mut clipboard, stream_id) { + return response; + } + } + + if tokio::time::timeout_at(deadline, &mut notified).await.is_err() { + // The wait itself timed out, but the result could have landed in the same + // instant the deadline elapsed (`on_file_contents_response` and this timeout race + // on the same clock); re-check once more under the lock before concluding the + // fetch is genuinely stuck, the same way `rail_wait` re-checks live state after + // its own timeout rather than assuming nothing arrived. + let mut clipboard = self.clipboard.lock().expect("clipboard state poisoned"); + if let Some(response) = take_finished_fetch_result(&mut clipboard, stream_id) { + return response; + } + // Only clear the shared fetch slot if it is still this call's own fetch: a + // session transition (`connect`) may have already cleared it, or a later call + // may have started a new fetch in the interim after finding the slot empty, and + // this stale timeout must not clobber that unrelated, still-in-progress fetch. + if clipboard + .active_fetch + .as_ref() + .is_some_and(|fetch| fetch.stream_id() == stream_id) + { + clipboard.active_fetch = None; + clipboard.active_fetch_lock_id = None; + } + return Response::typed_error(crate::ipc::AgentErrorCategory::Unavailable, "file fetch timed out"); + } + } + } + /// Requests that the active RDP session resize. pub fn resize(&self, width: u16, height: u16) -> Response { match self.try_resize(width, height) { @@ -1930,6 +2304,8 @@ mod tests { use ironrdp_client::output_channel::output_channel; use ironrdp_client::rdp::{RdpInputEvent, RdpInputSender}; + use ironrdp_cliprdr::chunked_fetch::{ChunkedFetch, ChunkedFetchProgress}; + use ironrdp_cliprdr::pdu::{ClipboardGeneralCapabilityFlags, FileDescriptor}; use ironrdp_input::{Database, Operation}; use ironrdp_pdu::input::fast_path::{FastPathInputEvent, KeyboardFlags}; use ironrdp_propertyset::PropertySet; @@ -1937,7 +2313,7 @@ mod tests { use super::{ ConnState, Daemon, DaemonOptions, Live, MAX_PENDING_RAIL_LAUNCHES, MAX_RAIL_RETAINED_EVENTS, MAX_UNICODE_TEXT_CHARS, NowEndpoint, OperationManager, RailLedger, RdpdrDriveConfig, ResizeError, Session, - consume_output, enqueue_unicode_text, notify, + consume_output, enqueue_unicode_text, filetime_to_unix_secs, notify, system_time_to_filetime, }; use crate::ipc::{Payload, Response}; use ironrdp_rpc::ipc::{RailEventKind, RailExecuteRequest, RailLaunchInfo}; @@ -1955,6 +2331,32 @@ mod tests { assert!(matches!(receiver.try_recv(), Err(mpsc::error::TryRecvError::Empty))); } + #[test] + fn filetime_round_trips_through_unix_seconds() { + // 2026-01-15T00:00:00Z, an arbitrary post-epoch instant with no special significance + // beyond being easy to eyeball. + let unix_secs = 1_768_435_200u64; + let time = std::time::UNIX_EPOCH + Duration::from_secs(unix_secs); + + let filetime = system_time_to_filetime(time); + assert_eq!(filetime_to_unix_secs(filetime), Some(unix_secs)); + } + + #[test] + fn filetime_before_unix_epoch_is_none() { + // FILETIME epoch (1601-01-01) itself: representable on the wire, but converts to a + // negative Unix time, which `filetime_to_unix_secs` reports as absent rather than wrap. + assert_eq!(filetime_to_unix_secs(0), None); + } + + #[test] + fn system_time_before_unix_epoch_encodes_as_zero() { + // `SystemTime` can represent times before the Unix epoch on this platform; not a real + // file's mtime, but must not panic. + let time = std::time::UNIX_EPOCH - Duration::from_secs(1); + assert_eq!(system_time_to_filetime(time), 0); + } + #[test] fn rail_ledger_reports_history_gaps_and_correlates_launches() { let mut ledger = RailLedger::new(7, 1, None); @@ -2120,6 +2522,7 @@ mod tests { rail_enabled, live: Arc::clone(&live), rail_notify: Arc::clone(&rail_notify), + clipboard_file_notify: Arc::new(tokio::sync::Notify::new()), operations: OperationManager::new(Arc::clone(&now_endpoint)), now_endpoint, }); @@ -2738,4 +3141,198 @@ mod tests { )); } } + + #[tokio::test(start_paused = true)] + async fn timed_out_fetch_does_not_clobber_a_fetch_started_after_it() { + // Regression test for the race the stream_id guard in `clipboard_get_file`'s timeout + // path closes: a session transition (`connect`) can clear a stuck fetch's slot out from + // under a still-sleeping waiter, a new call can then start a fresh fetch in that slot, + // and the original waiter's own deadline must not clobber that unrelated fetch when it + // finally elapses. + let (daemon, _input_rx, _live, _rail_notify) = active_rail_session(true); + { + let mut clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + clipboard.negotiated_capabilities = ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED; + clipboard.remote = Some(crate::clipboard::ClipboardContent::Files(vec![ + FileDescriptor::new("a.bin").with_file_size(10), + ])); + } + + let daemon = Arc::new(daemon); + let fetch_a = tokio::spawn({ + let daemon = Arc::clone(&daemon); + async move { daemon.clipboard_get_file(0).await } + }); + + // Let fetch_a register itself (stream_id 1, the first one `ClipboardState::default` + // hands out) before simulating the session transition. + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + { + let clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + assert_eq!( + clipboard.active_fetch.as_ref().map(ChunkedFetch::stream_id), + Some(1), + "fetch_a should have registered its fetch by now" + ); + } + + // Simulate `connect`'s reset (clearing fetch_a's slot without resolving it) immediately + // followed by a second, unrelated call starting a fresh fetch in the same slot: what + // matters for this test is that a *different* stream_id now occupies the slot. + { + let mut clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + clipboard.active_fetch = Some(ChunkedFetch::new_with_size_query(99, 0, 4096, None, u64::MAX)); + clipboard.active_fetch_lock_id = None; + clipboard.active_fetch_result = None; + clipboard.active_fetch_result_stream_id = None; + } + + // Push past fetch_a's 60s deadline; nothing ever answers its FileContentsRequest, so it + // times out. + tokio::time::advance(Duration::from_secs(61)).await; + let response = fetch_a.await.expect("fetch_a task did not panic"); + assert!(matches!( + response, + Response::Err(err) if err.message.contains("timed out") + )); + + // The unrelated fetch (stream_id 99) must still be there: fetch_a's stale timeout must + // not have cleared it. + let clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + assert_eq!( + clipboard.active_fetch.as_ref().map(ChunkedFetch::stream_id), + Some(99), + "fetch_a's timeout must not clobber a fetch it does not own" + ); + } + + #[tokio::test(start_paused = true)] + async fn stale_waiter_does_not_consume_a_later_fetch_result() { + // Regression test for the `active_fetch_result_stream_id` guard: an aborted fetch clears + // `active_fetch` in the same step it sets `active_fetch_result`, so a later, unrelated + // fetch can start and finish in the same slot before the first call's own waiter is + // scheduled to consume it. Without the guard, `take_finished_fetch_result` would hand + // fetch_a a completion meant for fetch_b, and fetch_b would then block out its own + // timeout for a result that was already taken out from under it. + let (daemon, _input_rx, _live, _rail_notify) = active_rail_session(true); + { + let mut clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + clipboard.negotiated_capabilities = ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED; + clipboard.remote = Some(crate::clipboard::ClipboardContent::Files(vec![ + FileDescriptor::new("a.bin").with_file_size(10), + ])); + } + + let daemon = Arc::new(daemon); + let fetch_a = tokio::spawn({ + let daemon = Arc::clone(&daemon); + async move { daemon.clipboard_get_file(0).await } + }); + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + { + let clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + assert_eq!( + clipboard.active_fetch.as_ref().map(ChunkedFetch::stream_id), + Some(1), + "fetch_a should have registered its fetch by now" + ); + } + + let notify = { + let guard = daemon.state.lock().expect("daemon state poisoned"); + Arc::clone(&guard.as_ref().expect("session installed").clipboard_file_notify) + }; + + // Simulate fetch_a's fetch being aborted and, before fetch_a's task ever gets scheduled + // to react, a second, unrelated fetch (stream_id 99) starting and completing in the same + // slot. A zero-size fetch starts already `Complete`, matching what a real completed fetch + // looks like from `take_finished_fetch_result`'s perspective. + { + let mut clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + clipboard.active_fetch = Some(ChunkedFetch::new(99, 1, 0, 4096, None, u64::MAX)); + clipboard.active_fetch_lock_id = None; + clipboard.active_fetch_result = Some(ChunkedFetchProgress::Complete); + clipboard.active_fetch_result_stream_id = Some(99); + } + notify.notify_waiters(); + tokio::task::yield_now().await; + + assert!( + !fetch_a.is_finished(), + "fetch_a must not have consumed fetch_b's result" + ); + { + let clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + assert_eq!( + clipboard.active_fetch.as_ref().map(ChunkedFetch::stream_id), + Some(99), + "fetch_b's still-unconsumed result must remain in the slot" + ); + assert!(matches!( + clipboard.active_fetch_result, + Some(ChunkedFetchProgress::Complete) + )); + } + + // fetch_a eventually times out on its own; fetch_b's result is untouched by that timeout + // (already covered by `timed_out_fetch_does_not_clobber_a_fetch_started_after_it`), and a + // real caller for stream_id 99 would still find its own result intact. + tokio::time::advance(Duration::from_secs(61)).await; + let response = fetch_a.await.expect("fetch_a task did not panic"); + assert!(matches!( + response, + Response::Err(err) if err.message.contains("timed out") + )); + } + + #[tokio::test(start_paused = true)] + async fn steady_progress_beyond_the_total_timeout_does_not_time_out() { + // Regression test for the idle-vs-total-transfer timeout fix: a fetch that keeps making + // progress at intervals under FETCH_TIMEOUT must not time out just because the *total* + // elapsed time exceeds FETCH_TIMEOUT, since each individual chunk is arriving well within + // its own allowance (matching `Cliprdr`'s per-request `transfer_timeout` semantics). + let (daemon, _input_rx, _live, _rail_notify) = active_rail_session(true); + { + let mut clipboard = daemon.clipboard.lock().expect("clipboard state poisoned"); + clipboard.negotiated_capabilities = ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED; + clipboard.remote = Some(crate::clipboard::ClipboardContent::Files(vec![ + FileDescriptor::new("a.bin").with_file_size(10), + ])); + } + + let notify = { + let guard = daemon.state.lock().expect("daemon state poisoned"); + Arc::clone(&guard.as_ref().expect("session installed").clipboard_file_notify) + }; + + let daemon = Arc::new(daemon); + let fetch = tokio::spawn({ + let daemon = Arc::clone(&daemon); + async move { daemon.clipboard_get_file(0).await } + }); + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + + // Two progress notifications 50s apart (100s cumulative, well past the old fixed 60s + // deadline), neither leaving a 60s idle gap. + for _ in 0..2 { + tokio::time::advance(Duration::from_secs(50)).await; + notify.notify_waiters(); + tokio::task::yield_now().await; + } + assert!( + !fetch.is_finished(), + "steady sub-60s progress must not have timed out the fetch" + ); + + // A genuine 60s idle gap (no further notify) still times it out. + tokio::time::advance(Duration::from_secs(61)).await; + let response = fetch.await.expect("fetch task did not panic"); + assert!(matches!( + response, + Response::Err(err) if err.message.contains("timed out") + )); + } } diff --git a/crates/ironrdp-rpc/src/ipc.rs b/crates/ironrdp-rpc/src/ipc.rs index 86905cd104..cca4570626 100644 --- a/crates/ironrdp-rpc/src/ipc.rs +++ b/crates/ironrdp-rpc/src/ipc.rs @@ -58,6 +58,23 @@ const CLIPBOARD_IMAGE_FRAME_HEADROOM: usize = 4 * 1024; /// this is a plain-text CLI argument, not a file, so it stays well under the image cap. pub const MAX_CLIPBOARD_HTML_BYTES: usize = 256 * 1024; +/// Maximum size in bytes of one file fetched via [`Request::ClipboardGetFile`]. +/// +/// Same rationale as [`MAX_CLIPBOARD_IMAGE_BYTES`]: derived from the transport's own frame limit, +/// not an unrelated constant that could exceed what a single IPC message can actually carry. The +/// daemon also bounds the fetch itself against this ceiling before issuing any wire request for +/// the file's contents, so an oversized remote file is rejected before any bytes are pulled over +/// the RDP session, not just before the IPC response is framed. +pub const MAX_CLIPBOARD_FILE_BYTES: usize = crate::transport::MAX_MESSAGE_LEN - CLIPBOARD_IMAGE_FRAME_HEADROOM; + +/// Maximum entries accepted in one [`Request::ClipboardSetFiles`] or returned by +/// [`Request::ClipboardListFiles`]. +/// +/// Generous for a real folder copy while still bounding decode-time allocation and the size of a +/// CLI listing. `ironrdp_cliprdr` itself caps a wire file list at 100,000 entries as a separate, +/// lower-level defense; this is an independent, smaller IPC-level bound. +pub const MAX_CLIPBOARD_FILE_LIST_ENTRIES: usize = 10_000; + /// Maximum contacts in one MS-RDPEI touch frame accepted over RPC. pub const MAX_TOUCH_CONTACTS: usize = 10; @@ -130,6 +147,56 @@ pub struct PenFrameRequest { pub contacts: Vec, } +/// One file's metadata: either offered locally via [`Request::ClipboardSetFiles`] (derived from +/// the local filesystem) or listed from the remote via [`Request::ClipboardListFiles`] (as +/// advertised over `CLIPRDR`, name-sanitized already by `ironrdp_cliprdr`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardFileEntry { + pub name: String, + /// Directory portion of the path within the copied collection, `\`-separated. `None` for a + /// file at the root of the collection. + pub relative_path: Option, + pub is_directory: bool, + /// Absent when the remote did not declare a size (directories never carry one; some peers + /// omit it for files too, which forces a `SIZE` round-trip before a fetch can begin). + pub size: Option, + /// Last write time as Unix seconds, converted from the wire's Windows FILETIME (100-ns + /// intervals since 1601-01-01). `None` when the remote did not declare one. + pub last_write_time: Option, +} + +fn clipboard_file_entry_size(entry: &ClipboardFileEntry) -> usize { + string_size(&entry.name) + + opt_string_size(entry.relative_path.as_deref()) + + 1 /* is_directory */ + + opt_u64_size(entry.size) + + opt_u64_size(entry.last_write_time) +} + +fn write_clipboard_file_entry(dst: &mut WriteCursor<'_>, entry: &ClipboardFileEntry) -> EncodeResult<()> { + write_string(dst, &entry.name)?; + write_opt_string(dst, entry.relative_path.as_deref())?; + write_bool(dst, entry.is_directory)?; + write_opt_u64(dst, entry.size)?; + write_opt_u64(dst, entry.last_write_time)?; + Ok(()) +} + +fn read_clipboard_file_entry(src: &mut ReadCursor<'_>) -> DecodeResult { + let name = read_string(src)?; + let relative_path = read_opt_string(src)?; + let is_directory = read_bool(src)?; + let size = read_opt_u64(src)?; + let last_write_time = read_opt_u64(src)?; + Ok(ClipboardFileEntry { + name, + relative_path, + is_directory, + size, + last_write_time, + }) +} + /// Validates and converts an RPC touch request into an MS-RDPEI touch event PDU. /// /// Rejects empty frames/contacts, count limits, unknown or illegal flag combinations, and @@ -476,6 +543,15 @@ pub enum Request { /// Set the local clipboard HTML fragment (at most [`MAX_CLIPBOARD_HTML_BYTES`]) and advertise /// it to the remote as the registered `HTML Format`. ClipboardSetHtml { html: String }, + /// Offer local files (at most [`MAX_CLIPBOARD_FILE_LIST_ENTRIES`] paths) to the remote via + /// the `CLIPRDR` file-list mechanism (`FileGroupDescriptorW`), replacing any previous local + /// clipboard content. Each path names a single regular file; a directory path is rejected. + ClipboardSetFiles { paths: Vec }, + /// List the remote's currently offered files, if any (metadata only; nothing is fetched). + ClipboardListFiles, + /// Fetch one file's full contents from the remote by its position in the last file list + /// [`Request::ClipboardListFiles`] returned, bounded at [`MAX_CLIPBOARD_FILE_BYTES`]. + ClipboardGetFile { index: i32 }, } // Manual `Debug` so the `Connect` payload's property *values* (which may include a password before @@ -604,6 +680,14 @@ impl fmt::Debug for Request { .debug_struct("ClipboardSetHtml") .field("html_len", &html.len()) .finish(), + // Never print file paths or names: they can carry as much sensitive information as + // clipboard text or file contents. + Self::ClipboardSetFiles { paths } => f + .debug_struct("ClipboardSetFiles") + .field("path_count", &paths.len()) + .finish(), + Self::ClipboardListFiles => f.write_str("ClipboardListFiles"), + Self::ClipboardGetFile { index } => f.debug_struct("ClipboardGetFile").field("index", index).finish(), } } } @@ -686,6 +770,11 @@ pub enum Payload { ClipboardImage(Option>), /// The remote clipboard's last HTML fragment, or `None` if unavailable. ClipboardHtml(Option), + /// The remote's currently offered files (metadata only), or `None` if the remote is not + /// currently offering any. + ClipboardFileList(Option>), + /// The full contents of one file fetched via [`Request::ClipboardGetFile`]. + ClipboardFile(Vec), } impl fmt::Debug for Payload { @@ -723,6 +812,12 @@ impl fmt::Debug for Payload { .debug_tuple("ClipboardHtml") .field(&html.as_ref().map(String::len)) .finish(), + // File names can be as sensitive as clipboard text; print counts only. + Self::ClipboardFileList(files) => f + .debug_tuple("ClipboardFileList") + .field(&files.as_ref().map(Vec::len)) + .finish(), + Self::ClipboardFile(data) => f.debug_tuple("ClipboardFile").field(&data.len()).finish(), } } } @@ -2053,6 +2148,21 @@ impl Encode for Payload { dst.write_u8(15); write_opt_string(dst, html.as_deref())?; } + Self::ClipboardFileList(files) => { + dst.write_u8(16); + write_bool(dst, files.is_some())?; + if let Some(files) = files { + let file_count: u16 = cast_length!("clipboard file list count", files.len())?; + dst.write_u16(file_count); + for file in files { + write_clipboard_file_entry(dst, file)?; + } + } + } + Self::ClipboardFile(data) => { + dst.write_u8(17); + write_bytes(dst, data)?; + } } Ok(()) } @@ -2080,6 +2190,13 @@ impl Encode for Payload { Self::ClipboardText(text) => opt_string_size(text.as_deref()), Self::ClipboardImage(png) => opt_bytes_size(png.as_deref()), Self::ClipboardHtml(html) => opt_string_size(html.as_deref()), + Self::ClipboardFileList(files) => { + 1 /* presence */ + + files.as_ref().map_or(0, |files| { + 2 /* file_count */ + files.iter().map(clipboard_file_entry_size).sum::() + }) + } + Self::ClipboardFile(data) => bytes_size(data), } } } @@ -2138,6 +2255,32 @@ impl Decode<'_> for Payload { } Ok(Self::ClipboardHtml(html)) } + 16 => { + let present = read_bool(src)?; + if !present { + return Ok(Self::ClipboardFileList(None)); + } + ensure_size!(in: src, size: 2); + let file_count = usize::from(src.read_u16()); + if file_count > MAX_CLIPBOARD_FILE_LIST_ENTRIES { + return Err(ironrdp_core::invalid_field_err!( + "clipboard file list", + "too many entries" + )); + } + let mut files = Vec::with_capacity(file_count); + for _ in 0..file_count { + files.push(read_clipboard_file_entry(src)?); + } + Ok(Self::ClipboardFileList(Some(files))) + } + 17 => { + let data = read_bytes(src)?; + if data.len() > MAX_CLIPBOARD_FILE_BYTES { + return Err(ironrdp_core::invalid_field_err!("clipboard file", "too large")); + } + Ok(Self::ClipboardFile(data)) + } _ => Err(ironrdp_core::invalid_field_err!("payload", "unknown tag", in: src)), } } @@ -2369,6 +2512,19 @@ impl Encode for Request { dst.write_u8(34); write_string(dst, html)?; } + Self::ClipboardSetFiles { paths } => { + dst.write_u8(35); + let path_count: u16 = cast_length!("clipboard file path count", paths.len())?; + dst.write_u16(path_count); + for path in paths { + write_string(dst, path)?; + } + } + Self::ClipboardListFiles => dst.write_u8(36), + Self::ClipboardGetFile { index } => { + dst.write_u8(37); + dst.write_i32(*index); + } } Ok(()) } @@ -2434,6 +2590,11 @@ impl Encode for Request { Self::ClipboardSet { text } => string_size(text), Self::ClipboardSetImage { png } => bytes_size(png), Self::ClipboardSetHtml { html } => string_size(html), + Self::ClipboardSetFiles { paths } => { + 2 /* path_count */ + paths.iter().map(|path| string_size(path)).sum::() + } + Self::ClipboardListFiles => 0, + Self::ClipboardGetFile { .. } => 4 /* index */, } } } @@ -2644,6 +2805,26 @@ impl Decode<'_> for Request { } Ok(Self::ClipboardSetHtml { html }) } + 35 => { + ensure_size!(in: src, size: 2); + let path_count = usize::from(src.read_u16()); + if path_count > MAX_CLIPBOARD_FILE_LIST_ENTRIES { + return Err(ironrdp_core::invalid_field_err!( + "clipboard file paths", + "too many paths" + )); + } + let mut paths = Vec::with_capacity(path_count); + for _ in 0..path_count { + paths.push(read_string(src)?); + } + Ok(Self::ClipboardSetFiles { paths }) + } + 36 => Ok(Self::ClipboardListFiles), + 37 => { + ensure_size!(in: src, size: 4); + Ok(Self::ClipboardGetFile { index: src.read_i32() }) + } _ => Err(ironrdp_core::invalid_field_err!("request", "unknown tag", in: src)), } } diff --git a/crates/ironrdp-testsuite-extra/tests/agent.rs b/crates/ironrdp-testsuite-extra/tests/agent.rs index c3442b9eb2..662d20c413 100644 --- a/crates/ironrdp-testsuite-extra/tests/agent.rs +++ b/crates/ironrdp-testsuite-extra/tests/agent.rs @@ -11,10 +11,10 @@ use ironrdp_daemon::now::{DVC_CHANNEL_NAME, INITIAL_ENDPOINT_TIMEOUT, NowEndpoin use ironrdp_input::MouseButton; use ironrdp_propertyset::PropertySet; use ironrdp_rpc::ipc::{ - AgentError, AgentErrorCategory, ConnState, KeyFilter, NowCapabilities, NowDiagnostics, NowExecutionKind, - NowExecutionRequest, NowStream, OperationEvent, OperationEventKind, OperationInfo, OperationState, Payload, - PropValue, PropertyDump, PropertyEntry, RailEvent, RailEventDump, RailEventKind, RailExecuteFailureReason, - RailExecuteRequest, RailLaunchInfo, RailStatusInfo, Request, Response, StatusInfo, + AgentError, AgentErrorCategory, ClipboardFileEntry, ConnState, KeyFilter, NowCapabilities, NowDiagnostics, + NowExecutionKind, NowExecutionRequest, NowStream, OperationEvent, OperationEventKind, OperationInfo, + OperationState, Payload, PropValue, PropertyDump, PropertyEntry, RailEvent, RailEventDump, RailEventKind, + RailExecuteFailureReason, RailExecuteRequest, RailLaunchInfo, RailStatusInfo, Request, Response, StatusInfo, }; use ironrdp_rpc::wire; @@ -173,6 +173,13 @@ fn request_variants_round_trip() { Request::ClipboardSetHtml { html: "clipboard html".to_owned(), }, + Request::ClipboardSetFiles { + paths: vec!["/home/user/report.pdf".to_owned(), "/home/user/photo.jpg".to_owned()], + }, + Request::ClipboardSetFiles { paths: vec![] }, + Request::ClipboardListFiles, + Request::ClipboardGetFile { index: 0 }, + Request::ClipboardGetFile { index: -1 }, ]; for request in &requests { @@ -323,6 +330,26 @@ fn response_variants_round_trip() { Response::Ok(Payload::ClipboardImage(Some(vec![0x89, b'P', b'N', b'G', 0, 0xFF]))), Response::Ok(Payload::ClipboardHtml(None)), Response::Ok(Payload::ClipboardHtml(Some("clipboard html".to_owned()))), + Response::Ok(Payload::ClipboardFileList(None)), + Response::Ok(Payload::ClipboardFileList(Some(vec![]))), + Response::Ok(Payload::ClipboardFileList(Some(vec![ + ClipboardFileEntry { + name: "report.pdf".to_owned(), + relative_path: None, + is_directory: false, + size: Some(4096), + last_write_time: Some(133_500_000_000_000_000), + }, + ClipboardFileEntry { + name: "subdir".to_owned(), + relative_path: Some("folder".to_owned()), + is_directory: true, + size: None, + last_write_time: None, + }, + ]))), + Response::Ok(Payload::ClipboardFile(vec![])), + Response::Ok(Payload::ClipboardFile(vec![1, 2, 3, 4, 5])), ]; for response in &responses { @@ -416,6 +443,26 @@ fn clipboard_debug_redacts_content() { let payload = Payload::ClipboardHtml(Some("secret-markup".to_owned())); let debug = format!("{payload:?}"); assert!(!debug.contains("secret-markup")); + + let request = Request::ClipboardSetFiles { + paths: vec!["/home/user/secret-plans.pdf".to_owned()], + }; + let debug = format!("{request:?}"); + assert!(!debug.contains("secret-plans")); + + let payload = Payload::ClipboardFileList(Some(vec![ClipboardFileEntry { + name: "secret-plans.pdf".to_owned(), + relative_path: None, + is_directory: false, + size: Some(1), + last_write_time: None, + }])); + let debug = format!("{payload:?}"); + assert!(!debug.contains("secret-plans")); + + let payload = Payload::ClipboardFile(b"secret-file-bytes".to_vec()); + let debug = format!("{payload:?}"); + assert!(!debug.contains("secret-file-bytes")); } #[test]