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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [pdu_decoding, rle_decompression, bitmap_stream, cliprdr_format, channel_processing]
target: [pdu_decoding, rle_decompression, bitmap_stream, cliprdr_format, cliprdr_channel_processing, channel_processing]

steps:
- uses: actions/checkout@v4
Expand Down
25 changes: 13 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/iron-remote-desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ macro_rules! make_bridge {
Self($crate::SessionBuilder::canvas_resized_callback(&self.0, callback))
}

// File transfer callbacks are protocol-specific and routed through
// extension() — see the RDP backend for available extension factories.

pub fn extension(&self, ext: $crate::Extension) -> Self {
Self($crate::SessionBuilder::extension(&self.0, ext))
}
Expand Down
34 changes: 25 additions & 9 deletions crates/ironrdp-client/src/rdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,9 @@ async fn active_session(

let mut active_stage = ActiveStage::new(connection_result);

// Timer interval for driving clipboard lock timeouts (5 second interval)
let mut cleanup_interval = tokio::time::interval(core::time::Duration::from_secs(5));

let disconnect_reason = 'outer: loop {
let outputs = tokio::select! {
frame = reader.read_pdu() => {
Expand Down Expand Up @@ -623,7 +626,7 @@ async fn active_session(
active_stage.graceful_shutdown()?
}
RdpInputEvent::Clipboard(event) => {
if let Some(cliprdr) = active_stage.get_svc_processor::<cliprdr::CliprdrClient>() {
if let Some(cliprdr) = active_stage.get_svc_processor_mut::<cliprdr::CliprdrClient>() {
if let Some(svc_messages) = match event {
ClipboardMessage::SendInitiateCopy(formats) => {
Some(cliprdr.initiate_copy(&formats)
Expand All @@ -637,14 +640,6 @@ async fn active_session(
Some(cliprdr.initiate_paste(format)
.map_err(|e| session::custom_err!("CLIPRDR", e))?)
}
ClipboardMessage::SendLockClipboard { clip_data_id } => {
Some(cliprdr.lock_clipboard(clip_data_id)
.map_err(|e| session::custom_err!("CLIPRDR", e))?)
}
ClipboardMessage::SendUnlockClipboard { clip_data_id } => {
Some(cliprdr.unlock_clipboard(clip_data_id)
.map_err(|e| session::custom_err!("CLIPRDR", e))?)
}
ClipboardMessage::SendFileContentsRequest(request) => {
Some(cliprdr.request_file_contents(request)
.map_err(|e| session::custom_err!("CLIPRDR", e))?)
Expand Down Expand Up @@ -678,6 +673,27 @@ async fn active_session(
}
}
}
_ = cleanup_interval.tick() => {
// Drive clipboard lock timeout cleanup
if let Some(cliprdr) = active_stage.get_svc_processor_mut::<cliprdr::CliprdrClient>() {
match cliprdr.drive_timeouts() {
Ok(svc_messages) => {
let frame = active_stage.process_svc_processor_messages(svc_messages)?;
if !frame.is_empty() {
vec![ActiveStageOutput::ResponseFrame(frame)]
} else {
Vec::new()
}
}
Err(e) => {
warn!(error = %e, "Clipboard timeout cleanup failed");
Vec::new()
}
}
} else {
Vec::new()
}
}
};

for out in outputs {
Expand Down
18 changes: 18 additions & 0 deletions crates/ironrdp-cliprdr-native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,22 @@ mod windows;
pub use crate::windows::{HWND, WinClipboard, WinCliprdrError, WinCliprdrResult};

mod stub;
use std::sync::OnceLock;
use std::time::Instant;

pub use crate::stub::{StubClipboard, StubCliprdrBackend};

/// Process-wide monotonic clock epoch for `CliprdrBackend::now_ms()` on native platforms.
///
/// Uses a lazily-initialized `Instant` so that all backends in the same process
/// share the same zero-point, producing comparable timestamps.
fn epoch() -> &'static Instant {
static EPOCH: OnceLock<Instant> = OnceLock::new();
EPOCH.get_or_init(Instant::now)
}

/// Returns monotonic milliseconds since process start, for use by native
/// `CliprdrBackend` implementations.
pub fn native_now_ms() -> u64 {
u64::try_from(epoch().elapsed().as_millis()).unwrap_or(u64::MAX)
}
8 changes: 8 additions & 0 deletions crates/ironrdp-cliprdr-native/src/stub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,12 @@ impl CliprdrBackend for StubCliprdrBackend {
fn on_request_format_list(&mut self) {
debug!("on_request_format_list");
}

fn now_ms(&self) -> u64 {
crate::native_now_ms()
}

fn elapsed_ms(&self, since: u64) -> u64 {
self.now_ms().saturating_sub(since)
}
}
8 changes: 8 additions & 0 deletions crates/ironrdp-cliprdr-native/src/windows/cliprdr_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,12 @@ impl CliprdrBackend for WinCliprdrBackend {
fn on_request_format_list(&mut self) {
self.send_event(BackendEvent::RemoteRequestsFormatList);
}

fn now_ms(&self) -> u64 {
crate::native_now_ms()
}

fn elapsed_ms(&self, since: u64) -> u64 {
self.now_ms().saturating_sub(since)
}
}
54 changes: 54 additions & 0 deletions crates/ironrdp-cliprdr/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]

### <!-- 1 -->Features

- [**breaking**] Add clipboard file transfer support per MS-RDPECLIP

Implements end-to-end clipboard file transfer (upload and download) across the
CLIPRDR channel. Key changes:

- Automatic clipboard locking: when `FileGroupDescriptorW` is detected in a
FormatList, the processor automatically sends Lock PDUs and manages the lock
lifecycle (expiry, cleanup, Unlock PDUs) internally.
- New `CliprdrBackend` methods with default implementations:
- `on_remote_file_list()` - called when remote announces files
- `on_file_contents_request()` - called when remote requests file data
- `on_outgoing_locks_cleared()` - called when locks are released
- `on_outgoing_locks_expired()` - called when locks expire
- `now_ms()` / `elapsed_ms()` - time source for timeout tracking
- New `drive_timeouts()` method for callers to invoke periodically to clean up
stale locks and pending requests.
- Comprehensive path sanitization to protect against path traversal attacks.

- [**breaking**] Remove `ClipboardMessage::SendLockClipboard` and `SendUnlockClipboard` variants

Lock/unlock is now managed internally by the `Cliprdr` processor. Backends no
longer need to handle these messages. Remove any code that matches on these
variants.

- [**breaking**] Rename `FileContentsFlags::DATA` to `FileContentsFlags::RANGE`

Aligns with MS-RDPECLIP 2.2.5.3 terminology where this flag indicates a
"range" request for file data bytes. Replace `FileContentsFlags::DATA` with
`FileContentsFlags::RANGE` in your code.

- [**breaking**] Change `FileContentsRequest::index` type from `u32` to `i32`

Per MS-RDPECLIP 2.2.5.3, the `lindex` field is a signed 32-bit integer.
This corrects the spec compliance. Update code to use `i32` for the index field.

- [**breaking**] Make `FileDescriptor` `#[non_exhaustive]` and add `relative_path` field

The `FileDescriptor` struct is now marked `#[non_exhaustive]` to allow future
field additions without breaking changes. A new `relative_path: Option<String>`
field has been added to support directory structure in file transfers.

**Migration:** Use the builder pattern instead of struct literals:
```rust
// Before (no longer compiles)
let desc = FileDescriptor { name: "file.txt".into(), file_size: Some(1024), ... };

// After
let desc = FileDescriptor::new("file.txt").with_file_size(1024);
```

## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.4.0...ironrdp-cliprdr-v0.5.0)] - 2025-12-18

### <!-- 4 -->Bug Fixes
Expand Down
6 changes: 6 additions & 0 deletions crates/ironrdp-cliprdr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@ categories.workspace = true
doctest = false
test = false
Comment thread
gabrielbauman marked this conversation as resolved.

[features]
# Internal (PRIVATE!) features used to aid testing.
# Don't rely on these whatsoever. They may disappear at any time.
__test = ["dep:visibility"]

[dependencies]
ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public
ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public
tracing = { version = "0.1", features = ["log"] }
bitflags = "2.9"
visibility = { version = "0.1", optional = true }

[lints]
workspace = true
Loading
Loading